Skip to content

perf!: yoke zero-copy node decoding and Jid Server enum - #513

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/yoke-zero-copy-and-server-enum
Apr 12, 2026
Merged

perf!: yoke zero-copy node decoding and Jid Server enum#513
jlucaso1 merged 2 commits into
mainfrom
perf/yoke-zero-copy-and-server-enum

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three major performance optimizations that eliminate allocation overhead in the node decode path:

  1. Jid Server enum: Replace Jid { server: Cow<'static, str> } with a Server u8 enum. Shrinks Jid from 56→32 bytes. Server comparisons become u8==u8 instead of string comparison.

  2. Yoke zero-copy decoding: Replace Arc<Node> with Arc<OwnedNodeRef> throughout the entire handler chain. OwnedNodeRef wraps Yoke<NodeRef<'static>, Vec<u8>>, keeping the decoded NodeRef borrowing directly from the decompressed buffer — zero allocation for attribute keys, values, and byte content.

  3. Full NodeRef migration + API ergonomics: Flipped the ProtocolNode trait so try_from_node_ref(&NodeRef) is the required method. Unified API naming — NodeRef::attrs() matches Node::attrs(), ValueRef::as_str() returns Cow<str> like NodeValue::as_str(). Added delegation methods to OwnedNodeRef so handlers can call node.attrs() directly without .get().


Breaking Changes

1. Jid.server: Cow<'static, str>Server enum

Type change: Jid { server: Cow<'static, str> }Jid { server: Server }
Same for: JidRef { server: Cow<'a, str> }JidRef { server: Server }

// Before
Jid::new("user", "s.whatsapp.net")
if jid.server == "s.whatsapp.net" { ... }

// After
Jid::new("user", Server::Pn)
if jid.server == Server::Pn { ... }
// Or use helpers:
if jid.is_pn() { ... }
  • cow_server_from_str() deleted — use Server::try_from(s) instead
  • Unknown servers rejected at parse time (JidError)
  • Server serializes as string ("s.whatsapp.net") for backward-compatible JSON
  • Variants: Pn, Lid, Group, Broadcast, Newsletter, Hosted, HostedLid, Messenger, Interop, Bot, Legacy

2. Handler trait: Arc<Node>Arc<OwnedNodeRef>

// Before
async fn handle(&self, client: Arc<Client>, node: Arc<Node>, ...) -> bool {
    let from = node.attrs().optional_jid("from");
}

// After — OwnedNodeRef has delegation methods, no .get() needed for common ops
async fn handle(&self, client: Arc<Client>, node: Arc<OwnedNodeRef>, ...) -> bool {
    let from = node.attrs().optional_jid("from");  // same ergonomics!
}

OwnedNodeRef delegates: tag(), attrs(), get_attr(), children(), get_optional_child(), get_optional_child_by_tag(), get_children_by_tag(), content_bytes(), content_str(), content_nodes().

Use .get() only when you need the full &NodeRef (e.g., passing to functions).

3. Unified API — NodeRef matches Node

Operation Node NodeRef OwnedNodeRef
Attr parser node.attrs() node.attrs() node.attrs()
Get attribute node.attrs.get("k") node.get_attr("k") node.get_attr("k")
Children node.children() node.children() node.children()
Find child node.get_optional_child("t") same same
Byte content node.content → NodeContent::Bytes node.content_bytes() node.content_bytes()
String content node.content_as_string() node.content_str() node.content_str()

4. ValueRef::as_str() now matches NodeValue::as_str()

// Before (footgun — returned None for JID attributes):
let from = node.get_attr("from").and_then(|v| v.as_str()); // None for JIDs!

// After — returns Cow<str> for both String AND JID variants:
let from = node.get_attr("from").map(|v| v.as_str()); // Always works

to_string_cow() deleted — as_str() does the same thing now.

5. Event types

// Before
Event::Notification(Node)          // deep-cloned on dispatch
Event::RawNode(Arc<Node>)

// After
Event::Notification(Arc<OwnedNodeRef>)  // cheap Arc clone, #[serde(skip)]
Event::RawNode(Arc<OwnedNodeRef>)       // #[serde(skip)]

6. ProtocolNode trait flipped

// Before — try_from_node was required
fn try_from_node(node: &Node) -> Result<Self>;

// After — try_from_node_ref is required
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self>;
fn try_from_node(node: &Node) -> Result<Self> { /* default: delegates */ }

7. IqSpec::parse_response

// Before
fn parse_response(&self, response: &Node) -> Result<Self::Response>;
// After
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response>;

8. Helper functions renamed (dropped _ref suffix)

All iq::node helpers take &NodeRef as canonical signature:
required_child, optional_child, required_attr, optional_attr, collect_children

Stanza parsers:
DeviceNotification::try_parse, BusinessNotification::try_parse, parse_lid_mappings_from_response, parse_prekeys_response

9. New public re-exports

whatsapp_rust::{Server, OwnedNodeRef, CompactString, Jid}

wacore_binary::{Server, Jid, JidRef, JidExt, Node, NodeRef, NodeValue, OwnedNodeRef, NodeContent, NodeContentRef, Attrs, CompactString, AttrParser, AttrParserRef, DeviceKey, ...}


What's zero-copy and what's not

Fully zero-copy (no allocation on decode path):

  • decrypt_frameOwnedNodeRef (yoke wraps decompressed buffer)
  • Handler dispatch via Arc<OwnedNodeRef> (cheap refcount)
  • All 33 IQ spec parse_response implementations
  • Message processing (parse_message_info, encryption, routing)
  • Receipt, notification, IB handling
  • Node waiters and event dispatch

Structurally required to_owned() (7 calls):

  • GroupNotificationAction::Create/Link/Unlink { raw: Node } — struct field
  • StreamError.raw / ConnectFailure.raw — event fields
  • IqError::Disconnected(Node)'static error type
  • enc_node clone crossing async task boundary

Test plan

  • cargo fmt --all
  • cargo clippy --all --tests — zero errors, zero warnings
  • cargo test -p wacore-binary -p wacore -p whatsapp-rust — all passing
  • cargo check -p wacore-binary --all-features — serde verified
  • CI / e2e tests

Summary by CodeRabbit

  • New Features

    • More efficient, zero-copy message/node handling and a new owned node-ref type for faster processing and lower memory use.
    • Exposed typed server identifier alongside JID for clearer address handling.
  • Bug Fixes

    • Stricter JID/server validation to reject invalid server identifiers.
  • Refactor

    • Large migration from owned payloads to reference-based parsing and unified server/address handling across the codebase.
  • Tests

    • Updated tests and benchmarks to match new APIs and parsing behavior.

Replace `Jid { server: Cow<'static, str> }` and
`JidRef { server: Cow<'a, str> }` with a `Server` u8 enum mapping
the 11 known WhatsApp server constants. This shrinks Jid from 56
to 32 bytes and NodeValue from 56 to 40 bytes.

- Server enum: Pn, Lid, Group, Broadcast, Newsletter, Hosted,
  HostedLid, Messenger, Interop, Bot, Legacy
- Server comparisons are now u8 == u8 instead of string comparison
- Delete cow_server_from_str() — no longer needed
- JidRef.server no longer borrows from the decode buffer, removing
  a lifetime dependency that simplifies the upcoming yoke integration
- Unknown servers are now rejected at parse time (JidError)

BREAKING CHANGE: `Jid.server` is now `Server` enum instead of
`Cow<'static, str>`. `Jid::new()` takes `Server` instead of `&str`.
Use `Server::Pn`, `Server::Lid`, etc. for known servers.
`server.as_str()` returns the string representation.
@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrates node handling to zero-copy views and a typed JID Server: adds yoke-backed OwnedNodeRef/NodeRef<'_>, introduces Server enum, updates encoder/decoder and JID handling, and rewires client, handlers, IQ parsing, appstate, and many tests to use reference-based node/JID APIs.

Changes

Cohort / File(s) Summary
Workspace deps
Cargo.toml, wacore/binary/Cargo.toml
Add yoke = { version = "0.7", features = ["derive"] } to workspace and wacore-binary dependencies.
Typed JID & re-exports
wacore/binary/src/jid.rs, wacore/binary/src/lib.rs, src/lib.rs, wacore/tests/jid_test.rs
Introduce Server enum; refactor Jid/JidRef to store typed Server instead of strings; update crate re-exports and associated tests to use typed Server variants.
Zero-copy node types & API
wacore/binary/src/node.rs, wacore/binary/src/lib.rs, src/test_utils.rs
Add NodeRef<'_> with yokeable derives and new accessors; introduce OwnedNodeRef as yoke-backed owned node view; provide helper to convert Node into Arc<OwnedNodeRef>.
Encoder / Decoder
wacore/binary/src/decoder.rs, wacore/binary/src/encoder.rs
Adapt JID read/write paths to use Server enum; strengthen validation with typed Server, update encoding size and serialization calls to use server.as_str().
Protocol/parsing API & derive
wacore/src/protocol/mod.rs, wacore/derive/src/lib.rs, many wacore/src/stanza/* files
Transition protocol parsing to zero-copy API: define required try_from_node_ref(&NodeRef<'_>) method; default try_from_node(&Node) delegating to it; update derive macros and ProtocolNode implementations accordingly.
Client pipeline & waiter plumbing
src/client.rs, src/request.rs, src/keepalive.rs, src/unified_session.rs
Rewrite client and waiter plumbing to use NodeRef and OwnedNodeRef types; update ACK handling, waiter channels, and send_iq response signatures to Arc<OwnedNodeRef>.
Stanza handlers & router
src/handlers/..., src/handlers/traits.rs, src/handlers/router.rs
Refactor all stanza handler handle methods and router dispatch to receive Arc<OwnedNodeRef>, replacing prior Arc<Node>; internally borrow NodeRef via .get() and update attribute/tag access accordingly.
Message/receipt/retry/send flows
src/message.rs, src/receipt.rs, src/retry.rs, src/send.rs, src/features/*
Migrate message decryption, receipt and retry logic, message sending, and feature handlers to use NodeRef/OwnedNodeRef and NodeContentRef; switch to typed Server usage in message and contact addressing.
IQ specs & parsers
Many files in wacore/src/iq/*
Switch numerous IQ specs and protocol node parsers from owned Node to zero-copy NodeRef consumptions; update IQ destination JIDs to use typed Server; adjust parsing internals to use new node reference APIs.
Appstate & patch decoding
wacore/appstate/src/patch_decode.rs, wacore/src/appstate_sync.rs
Add *_ref variants of patch list decoding APIs accepting &NodeRef<'_>; add new async methods in AppStateProcessor handling these references and external snapshot downloads; restructure patch list processing helper functions.
Helper/API surface
wacore/src/iq/spec.rs, wacore/src/iq/node.rs, wacore/src/request.rs
Adjust IqSpec trait method signature to consume &NodeRef<'_>; modify node helper functions for child/attribute extraction to accept NodeRef; reduce visibility for some helpers and remove unused ones.
Events, tests & imports
Many wacore/src/types/*, tests/**, and imports across src/*
Update event payload types to use Arc<OwnedNodeRef>; harmonize many imports to use crate-root wacore_binary::{Jid, Server, Node, OwnedNodeRef}; update extensive tests, e2e, benches, and utility helpers to comply with new APIs.
Misc & infra
.gitignore, benches, small modules
Add .codex to .gitignore; update benchmarks and minor test helpers to new APIs; fix attribute parser entry points and other small compatibility adjustments.

Sequence Diagram(s)

sequenceDiagram
    participant Net as Network
    participant Cli as Client
    participant Dec as Decoder
    participant Own as OwnedNodeRef
    participant Rout as Router
    participant Hnd as Handler
    participant Wait as Waiters

    Net->>Cli: receive protocol bytes
    Cli->>Dec: decode bytes -> NodeRef view
    Dec->>Own: OwnedNodeRef::new(buffer) (yoke)
    Cli->>Rout: dispatch(Arc<OwnedNodeRef>)
    Rout->>Hnd: handler.handle(Arc<OwnedNodeRef>)
    Hnd->>Cli: call client.* using node.get() -> &NodeRef
    Hnd->>Wait: resolve/fulfil waiter with Arc<OwnedNodeRef>
    Cli->>Net: send outgoing stanza / ACK
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐇 I nibble bytes and spare the heap,

I yoke the buffer, keep it neat,
NodeRefs hop light, no extra clone,
Servers typed — no stringly-grown,
A rabbit’s patch: small, swift, and sweet.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/yoke-zero-copy-and-server-enum

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude please review carefully, and also spot inconsistencies, more performance improvement (reducing unnecessary clones, allocations, conversions, etc.), and also try to find regressions, bugs, etc.

@claude

claude Bot commented Apr 11, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 6m 0s —— View job


PR Review: Perf/yoke zero copy and server enum

  • Review Server enum changes in wacore/binary/src/jid.rs
  • Review yoke/zero-copy integration in wacore/binary/src/node.rs
  • Review decoder/encoder changes for Server enum
  • Review handler chain migration (src/handlers/)
  • Review client-side changes (src/client.rs, src/send.rs, etc.)
  • Review IQ spec changes across wacore/src/iq/
  • Check for regressions, bugs, and inconsistencies
  • Check for further performance improvements

This PR has two excellent changes bundled together: (1) replacing Jid.server from Cow<str> to a Server enum, and (2) yoke-based zero-copy node decoding. The Server enum part is clean and complete. The yoke integration is well-designed but partially migrated (as the WIP commits indicate). Below is a detailed review of both, including compilation issues in the incomplete migration, bugs, and optimization suggestions.


Part 1: Server Enum — Clean, Well-Designed

The Server enum (wacore/binary/src/jid.rs:165-181) is well thought out:

  • #[repr(u8)], Copy, Default — eliminates heap allocation for every JID's server field
  • as_str() returns &'static str — zero-cost string access
  • TryFrom<&str> for parsing, PartialEq<str> for backward-compatible comparisons
  • Display delegates to as_str() — no allocation

Decoder (decoder.rs:113-114) — Minor concern with the fallback:

let server =
    crate::jid::Server::try_from(server_str.as_ref()).unwrap_or(crate::jid::Server::Pn);

Silently defaulting unknown servers to Pn (in read_jid_pair) hides protocol errors. If WhatsApp introduces a new server string, this would create JIDs with wrong servers. Consider logging a warning here, or returning an error. The read_ad_jid path correctly returns Err(BinaryError::AttrParse(...)) for unknown domain types — the JID_PAIR path should be similarly strict.

Encoder (encoder.rs:362-369)server_to_domain_type correctly maps Server enum variants to wire bytes. The existing comment about staying in sync with decoder is good.

IQ specs migration — All 14+ IQ spec files are cleanly updated from Jid::new("", SERVER_JID) / Jid::new("", GROUP_SERVER) to Jid::new("", Server::Pn) / Jid::new("", Server::Group). Consistent and correct.

src/send.rs — Clean migration from string constants to enum: Jid::new(&pn, Server::Pn), Jid::new(*lid_user, Server::Lid). No issues.

One nit: src/message.rs repeats Server::try_from(lid_server).unwrap_or(Server::Lid) six times in test code. Consider extracting to a helper like fn parse_lid_server(s: &str) -> Server for DRYness — though this is test-only code so low priority.


Part 2: Yoke Zero-Copy — Great Design, Incomplete Migration

The OwnedNodeRef design (node.rs:566-596) is solid:

pub struct OwnedNodeRef {
    inner: Yoke<NodeRef<'static>, Vec<u8>>,
}

This is the right approach — the raw buffer is owned, and NodeRef borrows from it via yoke's self-referential type. Handlers that only read attributes (the majority) avoid all string allocation. Only handlers that need owned data (e.g., AckHandler) call to_owned_node().

The decrypt_frame change in client.rs:1500-1506 correctly transitions from "decode then clone everything" to "hand buffer to yoke, parse zero-copy":

let buffer = unpacked_data_cow.into_owned();
match wacore_binary::OwnedNodeRef::new(buffer) { ... }

However, the handler chain migration is incomplete and has many type mismatches. Here's a comprehensive list:


Compilation Issues (WIP Migration Gaps)

These are the spots where handler signatures were updated to Arc<OwnedNodeRef> but the bodies still use Node / Arc<Node> APIs:

1. src/handlers/iq.rs:27-34 — Calls node.attrs.get(...) and DisplayableNode(&node) on Arc<OwnedNodeRef>. Should be:

let nr = node.get();
if !client.handle_iq(nr).await {
    if nr.get_attr("type").is_some_and(|v| v.as_str() == Some("result")) {
        debug!("...: {}", DisplayableNodeRef(nr));
    } else {
        warn!("...: {}", DisplayableNodeRef(nr));
    }
}

Also imports DisplayableNode and Node which should become DisplayableNodeRef and remove Node. Fix this →

2. src/handlers/presence.rs:25-39 — Calls node.attrs.get("from"), node.attrs.get("type"), node.attrs.get("last") on Arc<OwnedNodeRef>. Must use node.get().get_attr(...). The ValueRef::as_str() returns Option<&str> (not Cow), so chaining needs adjustment. Fix this →

3. src/handlers/chatstate.rs:58ChatstateStanza::parse(&node) receives &Arc<OwnedNodeRef>, but parse() likely expects &Node. Needs conversion via node.to_owned_node() or the parse function needs a NodeRef variant.

4. src/handlers/message.rs:36 — Calls node.attrs() which exists on Node (returns AttrParser), not on OwnedNodeRef. Should be node.get().attr_parser().

5. src/handlers/ib.rs:30-35 — Passes &node (&Arc<OwnedNodeRef>) to handle_ib_impl(client, &node) which expects &Node at line 35. Needs either &node.to_owned_node() or migrating handle_ib_impl to accept &NodeRef.

6. src/handlers/notification.rs:38-43 — Same pattern: passes &node to handle_notification_impl(&client, &node) which expects &Node at line 43.

7. src/handlers/unimplemented.rs:47 — Calls node.tag on Arc<OwnedNodeRef>. Should be node.get().tag.

8. src/handlers/router.rs:107-174 (tests)MockHandler::handle still has _node: Arc<Node> in its signature, and test dispatch calls still create Arc::new(Node::new(...)). These need to be updated to Arc<OwnedNodeRef>.

9. src/client.rs:handle_iq (line 3157-3166) — Now takes &NodeRef<'_> but body calls node.attrs.get("type")NodeRef.attrs is a Vec, not Attrs, so it doesn't have .get(&str). Must use node.get_attr("type"). Also node.attrs() should be node.attr_parser().

10. src/client.rs:handle_stream_error (line 2982-2997) — Takes &NodeRef<'_> but calls node.attrs() (should be node.attr_parser()). Also the nested n.attrs() on a child NodeRef has the same issue.

11. src/client.rs:handle_connect_failure (line 3100-3104) — Same: node.attrs() should be node.attr_parser(). Also line 3142: DisplayableNode(node) should be DisplayableNodeRef(node). And line 3151: raw: Some(node.clone()) — can't clone &NodeRef into what's expected to be Option<Node>.

12. src/client.rs:handle_success (line 1929)node.attrs.get("lid") won't compile on &NodeRef. Must use node.get_attr("lid").

13. src/client.rs:update_server_time_offset (line 3265-3266) — Takes &NodeRef but passes to unified_session.update_server_time_offset(node) which expects &Node (unified_session.rs:46).

14. src/client.rs:resolve_node_waiters (line 3244) — Expects &Arc<Node> but called with &Arc<OwnedNodeRef> in process_node.

15. src/client.rs:maybe_deferred_ack (line 1698) — Expects Arc<Node> but called with Arc<OwnedNodeRef> in process_node. And its inner send_ack_for and build_ack_node (line 3598) also expect &Node.

16. src/client.rs:Event::RawNode (line 1635)Event::RawNode(Arc::clone(&node)) — the event type is RawNode(Arc<Node>) (wacore events.rs:472) but node is now Arc<OwnedNodeRef>.


Potential Bug in Completed Code

src/usync.rs:97 — The change from &user_list.user.server to user_list.user.server.as_str() is correct in that it produces &str, but note that clear_device_record takes &str. Previously &Cow<str> auto-deref'd to &str. With Server, as_str() is the right call. This is correct.

src/handlers/notification.rs:371from_jid.server.as_str() — correct, same pattern.


Performance Suggestions

1. Avoid double-parse in decrypt_frameunpacked_data_cow.into_owned() always copies if the Cow is Borrowed. If the unpack function could be modified to return Vec<u8> directly (rather than Cow<[u8]>) when the caller passes ownership of the encrypted frame, it would avoid one copy. Low priority since decompression usually produces owned data anyway.

2. Strategy for handler migration — Recommend a 3-tier approach:

  • Tier 1 (zero-copy): Handlers that only read attrs (basic.rs, presence.rs, chatstate.rs) — use node.get() to get &NodeRef, never call to_owned_node().
  • Tier 2 (lazy owned): Handlers that need Node for downstream APIs (ib.rs, notification.rs, receipt.rs, message.rs) — call node.to_owned_node() at the boundary.
  • Tier 3 (future): Migrate downstream APIs (handle_ib_impl, handle_notification_impl, handle_receipt, build_ack_node) to accept &NodeRef for full zero-copy.

3. NodeFilter::matches in node waiters — Currently expects &Node. Since waiters only check tag and a few attrs, migrating to accept &NodeRef would avoid the to_owned_node() conversion for every node that has active waiters.

4. src/message.rs test code — The repeated Server::try_from(lid_server).unwrap_or(Server::Lid) (6 occurrences) could use a local helper.


Summary

Area Status
Server enum (jid.rs, encoder, decoder) Complete, clean
IQ spec migration (14 files) Complete, clean
send.rs / message.rs / pair_code.rs Complete, clean
OwnedNodeRef + yoke design Complete, well-designed
decrypt_frameOwnedNodeRef Complete, correct
process_node body migration Partially done, ~6 remaining issues
Handler chain (8 handlers) Signatures updated, bodies not migrated
Router tests Not yet updated
Downstream APIs (unified_session, build_ack_node, etc.) Not yet updated

The Server enum change is ready to ship. The yoke integration design is solid but needs the handler bodies and downstream API signatures completed. I'd estimate ~16 compilation fixes remaining to get this building. The "fix forward" approach (updating one handler at a time, verifying with cargo check) would be the most efficient path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f45702548e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/decoder.rs Outdated
Comment on lines +113 to +114
let server =
crate::jid::Server::try_from(server_str.as_ref()).unwrap_or(crate::jid::Server::Pn);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve unknown JID domains when decoding JID_PAIR

Defaulting unknown JID_PAIR servers to Server::Pn silently rewrites identities from unsupported/new domains into @s.whatsapp.net, which can route stanzas down the wrong PN-specific code paths (session lookup/deletion, recipient resolution, and user identity matching) instead of failing fast or preserving the original domain. This regression is triggered whenever the wire payload contains a server string outside the current enum (including future WhatsApp domains).

Useful? React with 👍 / 👎.

Comment thread wacore/binary/src/jid.rs
Comment on lines 338 to +339
fn is_empty(&self) -> bool {
self.server().is_empty()
self.user().is_empty()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep server-only JIDs non-empty

Changing is_empty() to check only user makes valid server-only JIDs (for example "s.whatsapp.net") appear empty. RequestUtils::build_iq_node gates target emission on !target.is_empty(), so any IQ that intentionally uses a server-only target will now silently drop the target attribute and send a different request than intended.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (15)
wacore/binary/src/jid.rs (2)

705-710: ⚠️ Potential issue | 🟠 Major

Suppress .agent for Server::HostedLid too.

Both Display impls still append .agent for @hosted.lid when agent > 0. That changes the canonical string form to user.<agent>:<device>@hosted.lid, which is inconsistent with the @hosted branch and can break round-trips for decoded hosted-LID JIDs.

Suggested fix
-                if !matches!(self.server, Server::Pn | Server::Lid | Server::Hosted) {
+                if !matches!(
+                    self.server,
+                    Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
+                ) {
                     write!(f, ".{}", self.agent)?;
                 }
             }
@@
-            if self.agent > 0 && !matches!(self.server, Server::Pn | Server::Lid | Server::Hosted) {
+            if self.agent > 0
+                && !matches!(
+                    self.server,
+                    Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
+                )
+            {
                 write!(f, ".{}", self.agent)?;
             }

Also applies to: 723-731

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 705 - 710, The Display implementations
for JID append ".{agent}" when self.agent > 0 but currently skip only
Server::Pn, Server::Lid, and Server::Hosted; update the condition to also skip
Server::HostedLid so the agent is not appended for hosted-lid addresses
(preventing outputs like user.<agent>:<device>@hosted.lid); locate the branches
that check matches!(self.server, Server::Pn | Server::Lid | Server::Hosted) in
the Display impl(s) (the blocks that call write!(f, ".{}", self.agent)?), add
Server::HostedLid to that matches! list in both places (the other occurrence
around the 723-731 range) so hosted-lid follows the same suppression logic.

652-685: ⚠️ Potential issue | 🟠 Major

Reject oversized agent values before casting to u8.

In the user.agent:device@server fallback path, num_val as u8 truncates silently. user.300:1@hosted currently parses as agent 44 instead of failing, while the sibling dotted-agent branch below correctly rejects out-of-range values.

Suggested fix
         if server != DEFAULT_USER_SERVER
             && server != HIDDEN_USER_SERVER
             && let Some((u, last_part)) = user.rsplit_once('.')
             && let Ok(num_val) = last_part.parse::<u16>()
         {
             user = u;
-            agent = num_val as u8;
+            if num_val > u8::MAX as u16 {
+                return Err(JidError::InvalidFormat(format!(
+                    "Agent component out of range: {num_val}"
+                )));
+            }
+            agent = num_val as u8;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 652 - 685, The early branch that
handles dotted-agent when server != DEFAULT_USER_SERVER && server !=
HIDDEN_USER_SERVER (the if let Some((u, last_part)) = user.rsplit_once('.') &&
let Ok(num_val) = last_part.parse::<u16>() block) currently assigns agent =
num_val as u8 without range-checking and can truncate values; add the same
bounds check used in the later fallback branch (if num_val > u8::MAX as u16 {
return Err(JidError::InvalidFormat(format!("Agent component out of range:
{num_val}"))); }) before casting, and keep the assignments user = u and agent =
num_val as u8 only after the check.
src/handlers/iq.rs (1)

26-34: ⚠️ Potential issue | 🔴 Critical

Dereference OwnedNodeRef before passing to IQ helpers.

This handler treats Arc<OwnedNodeRef> as a Node/NodeRef, causing three compile errors: handle_iq() expects &NodeRef<'_>, attrs requires dereferencing via .get(), and DisplayableNode expects &Node.

Fix
     async fn handle(&self, client: Arc<Client>, node: Arc<wacore_binary::OwnedNodeRef>, _cancelled: &mut bool) -> bool {
-        if !client.handle_iq(&node).await {
-            if node.attrs.get("type").is_some_and(|s| s == "result") {
+        let node_ref = node.get();
+        if !client.handle_iq(node_ref).await {
+            if node_ref.attrs.get("type").is_some_and(|s| s == "result") {
+                let owned = node.to_owned_node();
                 debug!(
                     "Received late IQ response (waiter already removed): {}",
-                    DisplayableNode(&node)
+                    DisplayableNode(&owned)
                 );
             } else {
-                warn!("Received unhandled IQ: {}", DisplayableNode(&node));
+                let owned = node.to_owned_node();
+                warn!("Received unhandled IQ: {}", DisplayableNode(&owned));
             }
         }
         true
     }

Alternatively, use DisplayableNodeRef(&node_ref) instead of converting to owned for better efficiency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/iq.rs` around lines 26 - 34, The handler is treating
Arc<OwnedNodeRef> as a Node/NodeRef causing type errors; explicitly dereference
the Arc<OwnedNodeRef> to a &NodeRef before calling helper APIs. Inside handle(),
bind a reference like let node_ref = node.as_ref() or &*node and then call
client.handle_iq(node_ref). Use node_ref.attrs.get(...) for attributes and pass
node_ref to DisplayableNode (or use DisplayableNodeRef(node_ref) as suggested)
so all three places (handle_iq, attrs.get, DisplayableNode) receive the correct
referenced type.
src/handlers/message.rs (1)

32-113: ⚠️ Potential issue | 🔴 Critical

This handler still mixes OwnedNodeRef reads with an Arc<Node> message queue.

The code has two compile errors:

  1. node.attrs() fails because OwnedNodeRef does not have an attrs() method; you must call node.get().attrs() to access the inner NodeRef.
  2. tx.try_send(node) fails because the worker queue is typed as async_channel::unbounded::<Arc<Node>>() but you are trying to send Arc<OwnedNodeRef>. You must convert: Arc::new(node.to_owned_node()).
🐛 Minimal compatible fix
-        let chat_id = match node.attrs().optional_jid("from") {
+        let chat_id = match node.get().attrs().optional_jid("from") {
             Some(jid) => jid.to_string(),
             None => {
                 warn!("Message stanza missing required 'from' attribute");
                 return false;
             }
@@
-        if let Err(e) = tx.try_send(node) {
+        if let Err(e) = tx.try_send(Arc::new(node.to_owned_node())) {
             warn!("Failed to enqueue message for processing: {e}");
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.rs` around lines 32 - 113, The handler mixes
OwnedNodeRef and Arc<Node>: replace direct calls to node.attrs() with
node.get().attrs() to access the inner NodeRef, and when enqueuing convert the
OwnedNodeRef into the queue's Arc<Node> type (e.g., wrap the owned node:
Arc::new(node.to_owned_node()) or whatever conversion method exists) before
calling tx.try_send; update the code paths around handle, node.get(), attrs(),
message_queues and tx.try_send to perform these swaps so types align.
wacore/binary/src/decoder.rs (1)

131-144: ⚠️ Potential issue | 🟠 Major

Preserve agent-coded PN AD_JIDs on decode.

The write path still emits domain_type = jid.agent for unmapped servers, so a PN AD_JID with a non-zero agent byte can be produced by server_to_domain_type(). The new match rejects those values, which breaks encode/decode symmetry for otherwise valid JIDs.

💡 Suggested fix
         let server = match agent {
-            0 => crate::jid::Server::Pn,
             1 => crate::jid::Server::Lid,
             128 => crate::jid::Server::Hosted,
             129 => crate::jid::Server::HostedLid,
             n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
+            n if (n & 128) == 0 => crate::jid::Server::Pn,
             _ => {
                 return Err(BinaryError::AttrParse(format!(
                     "AD_JID invalid domain type: {agent}"
                 )));
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 131 - 144, The decoder currently
rejects unknown agent values for AD_JID, breaking encode/decode symmetry; change
the fallback so unmapped agent bytes are treated as PN (preserving agent-coded
PN AD_JID) instead of returning an error. In the match on `agent` (the AD_JID
domain type parsing) return crate::jid::Server::Pn for the default/unknown-arm
(or otherwise accept and map unmapped `agent` values to `Server::Pn`) so values
emitted by `server_to_domain_type()` round-trip instead of erroring.
src/handlers/presence.rs (1)

24-40: ⚠️ Potential issue | 🔴 Critical

Fix the OwnedNodeRef access pattern before this can build.

Arc<OwnedNodeRef> does not expose attrs; only OwnedNodeRef::get() returns the borrowed NodeRef, and from there you need get_attr() or .attrs field access. This handler fails to compile with E0609.

💡 Suggested fix
     async fn handle(&self, client: Arc<Client>, node: Arc<wacore_binary::OwnedNodeRef>, _cancelled: &mut bool) -> bool {
-        let from_jid = match node.attrs.get("from").and_then(|v| v.to_jid()) {
+        let node_ref = node.get();
+        let from_jid = match node_ref.get_attr("from").and_then(|v| v.to_jid()) {
             Some(jid) => jid,
             None => {
                 debug!(target: "PresenceHandler", "Presence stanza missing or invalid 'from' attribute");
                 return true;
             }
         };
 
-        let unavailable = node.attrs.get("type").is_some_and(|v| v == "unavailable");
+        let unavailable = node_ref
+            .get_attr("type")
+            .and_then(|v| v.as_str())
+            .is_some_and(|v| v == "unavailable");
 
         // Parse last_seen from 'last' attribute if present
         let last_seen = node
-            .attrs
-            .get("last")
-            .and_then(|v| v.as_str().parse::<i64>().ok())
+            _ref.get_attr("last")
+            .and_then(|v| v.as_str())
+            .and_then(|v| v.parse::<i64>().ok())
             .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/presence.rs` around lines 24 - 40, The handler uses
Arc<wacore_binary::OwnedNodeRef> (parameter name node) as if it exposes attrs
directly, causing E0609; change accesses to call node.get() to obtain the
borrowed NodeRef and then use NodeRef methods (e.g., get_attr("from") /
get_attr("type") / get_attr("last") or access the .attrs on the borrowed
NodeRef) when parsing from_jid, unavailable, and last_seen inside async fn
handle; update all occurrences referencing node.attrs to use
node.get().get_attr(...) (or node.get().attrs) and adjust the last_seen parsing
to parse the string from the borrowed attribute value accordingly.
src/client.rs (9)

3100-3105: ⚠️ Potential issue | 🔴 Critical

Compilation error: No attrs() method in handle_connect_failure.

Same issue as handle_stream_error — use attr_parser() instead.

Proposed fix
     pub(crate) async fn handle_connect_failure(&self, node: &wacore_binary::NodeRef<'_>) {
         self.expected_disconnect.store(true, Ordering::Relaxed);
         self.shutdown_notifier.notify(usize::MAX);
 
-        let mut attrs = node.attrs();
+        let mut attrs = node.attr_parser();
         let reason_code = attrs.optional_u64("reason").unwrap_or(0) as i32;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3100 - 3105, In handle_connect_failure replace
the call to node.attrs() with node.attr_parser() (same fix as
handle_stream_error): obtain the attribute parser via let mut attrs =
node.attr_parser(); and then use attrs.optional_u64("reason").unwrap_or(0) as
i32 to read the reason code, ensuring the parser API is used instead of the
non-existent attrs() method.

1412-1416: ⚠️ Potential issue | 🔴 Critical

Compilation error: OwnedNodeRef has no tag field.

At this point, node is an OwnedNodeRef returned from decrypt_frame. To access tag, you need to call .get() first to obtain the inner NodeRef.

Proposed fix
                         // - Everything else: spawned concurrently for parallelism
                         let process_inline = matches!(
-                            node.tag.as_ref(),
+                            node.get().tag.as_ref(),
                             "success" | "failure" | "stream:error" | "message" | "ib"
                         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1412 - 1416, The code attempts to access node.tag
on an OwnedNodeRef returned by decrypt_frame, causing a compilation error;
update the matches call to use node.get().tag (i.e., call .get() on the
OwnedNodeRef before inspecting tag) so process_inline is computed from
node.get().tag, ensuring you dereference the OwnedNodeRef returned by
decrypt_frame when checking tags like "success" | "failure" | "stream:error" |
"message" | "ib".

1926-1941: ⚠️ Potential issue | 🔴 Critical

Compilation error: Cannot index node.attrs with get() on NodeRef.

NodeRef.attrs is a slice [(Cow<'_, str>, ValueRef<'_>)], not a HashMap. The get("lid") call doesn't work. Use get_attr("lid") or iterate to find the attribute.

Proposed fix
         // Extract LID from the node before spawning (node isn't Send).
-        let lid_from_server = match node.attrs.get("lid") {
-            Some(lid_value) => match lid_value.to_jid() {
+        let lid_from_server = match node.get_attr("lid") {
+            Some(lid_value) => match lid_value.to_jid() {
                 Some(lid) => Some(lid),
                 None => {
-                    warn!("Failed to parse LID from success stanza: {lid_value}");
+                    warn!("Failed to parse LID from success stanza: {:?}", lid_value);
                     None
                 }
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1926 - 1941, The code attempts to index
node.attrs with get("lid") which fails because NodeRef.attrs is a slice of
(Cow<str>, ValueRef) pairs; change the extraction in the lid_from_server block
to use the NodeRef API or iterate the slice: call node.get_attr("lid") if
available or iterate node.attrs to find the tuple whose 0 equals "lid", then
convert its ValueRef to_jid() as before; update the lid_from_server match to use
that retrieved attribute (refer to symbols: node.attrs, node.get_attr,
lid_from_server, self.update_server_time_offset) and preserve the same logging
paths when attribute is missing or parsing fails.

1632-1651: ⚠️ Potential issue | 🔴 Critical

Multiple type mismatches: Arc<OwnedNodeRef> vs Arc<Node>.

The Event::RawNode variant and resolve_node_waiters function still expect Arc<Node>, but node is now Arc<OwnedNodeRef>. Either:

  1. Update the Event::RawNode type and NodeWaiter/resolve_node_waiters to use Arc<OwnedNodeRef>, or
  2. Convert to owned Node before passing (less efficient, defeats zero-copy goal)

The same issue applies to maybe_deferred_ack at line 1682 which expects Arc<Node>.

Proposed fix (option 1 - update signatures)

Update NodeWaiter and resolve_node_waiters:

 struct NodeWaiter {
     filter: NodeFilter,
-    tx: futures::channel::oneshot::Sender<Arc<Node>>,
+    tx: futures::channel::oneshot::Sender<Arc<wacore_binary::OwnedNodeRef>>,
 }
 
 fn resolve_waiters(
     waiters_mutex: &std::sync::Mutex<Vec<NodeWaiter>>,
     counter: &AtomicUsize,
-    node: &Arc<Node>,
+    node: &Arc<wacore_binary::OwnedNodeRef>,
 ) {

Update NodeFilter::matches to take NodeRef:

-    fn matches(&self, node: &Node) -> bool {
-        node.tag == self.tag
+    fn matches(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
+        node.tag.as_ref() == self.tag
             && self
                 .attrs
                 .iter()
-                .all(|(k, v)| node.attrs.get(k.as_str()).is_some_and(|attr| *attr == *v))
+                .all(|(k, v)| node.get_attr(k).is_some_and(|attr| attr.as_str() == Some(v.as_str())))
     }

Update maybe_deferred_ack signature and send_ack_for to convert:

-    async fn maybe_deferred_ack(self: &Arc<Self>, node: Arc<Node>) {
+    async fn maybe_deferred_ack(self: &Arc<Self>, node: Arc<wacore_binary::OwnedNodeRef>) {
         if self.synchronous_ack {
-            if let Err(e) = self.send_ack_for(&node).await {
+            if let Err(e) = self.send_ack_for(&node.to_owned_node()).await {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1632 - 1651, The code currently passes an
Arc<OwnedNodeRef> (variable node) to places expecting Arc<Node>, causing type
mismatches; update the types to accept OwnedNodeRef to preserve zero-copy:
change Event::RawNode variant, the NodeWaiter type and
resolve_node_waiters(&node) signature to accept Arc<OwnedNodeRef> (or
NodeRef/OwnedNodeRef as used in your codebase), update NodeFilter::matches to
take a NodeRef/OwnedNodeRef instead of Arc<Node>, and update maybe_deferred_ack
and send_ack_for signatures/usages to accept Arc<OwnedNodeRef> (converting to
owned Node only where strictly necessary). Ensure all call sites that dispatch
Event::RawNode, invoke resolve_node_waiters, call NodeFilter::matches, and call
maybe_deferred_ack/send_ack_for are updated accordingly to the new
OwnedNodeRef-based types.

2982-2997: ⚠️ Potential issue | 🔴 Critical

Compilation error: No attrs() method on NodeRef.

NodeRef doesn't have an attrs() method. Based on the code pattern, you should use attr_parser() to get an AttrParser or access the attrs field directly and iterate.

Proposed fix
     pub(crate) async fn handle_stream_error(&self, node: &wacore_binary::NodeRef<'_>) {
         self.is_logged_in.store(false, Ordering::Relaxed);
 
-        let mut attrs = node.attrs();
-        let code_cow = attrs.optional_string("code");
+        let mut attrs = node.attr_parser();
+        let code_cow = attrs.optional_string("code");
         let code = code_cow.as_deref().unwrap_or("");
         let conflict_type = node
             .get_optional_child("conflict")
             .map(|n| {
-                n.attrs()
+                n.attr_parser()
                     .optional_string("type")
                     .as_deref()
                     .unwrap_or("")
                     .to_string()
             })
             .unwrap_or_default();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 2982 - 2997, The compile error occurs because
wacore_binary::NodeRef has no attrs() method; update handle_stream_error to use
node.attr_parser() (or access the attr_parser field) instead: replace let mut
attrs = node.attrs() with let mut parser = node.attr_parser(); then get the code
via parser.optional_string("code").as_deref().unwrap_or(""); similarly, when
mapping the "conflict" child, call
n.attr_parser().optional_string("type").as_deref().unwrap_or("").to_string() so
all attribute parsing uses AttrParser methods instead of attrs(); keep existing
variable names (code_cow/code and conflict_type) and behavior otherwise.

3698-3731: ⚠️ Potential issue | 🔴 Critical

Test compilation errors: should_ack signature changed.

The tests at lines 3719-3725 call client.should_ack(&receipt_node) and client.should_ack(&notification_node) with &Node, but should_ack now takes &NodeRef. The tests need to be updated to work with the new API.

Proposed fix

Either:

  1. Create OwnedNodeRef from marshaled bytes and call should_ack(owned.get())
  2. Add a helper method that accepts &Node for testing
  3. Update the test to use the new types
+        // Helper to check should_ack with Node (for tests)
+        fn should_ack_node(client: &Client, node: &Node) -> bool {
+            let bytes = wacore_binary::marshal::marshal_auto(node).unwrap();
+            let owned = wacore_binary::OwnedNodeRef::new(bytes).unwrap();
+            client.should_ack(owned.get())
+        }
+
         assert!(
-            client.should_ack(&receipt_node),
+            should_ack_node(&client, &receipt_node),
             "should_ack must still return TRUE for <receipt> stanzas."
         );
         assert!(
-            client.should_ack(&notification_node),
+            should_ack_node(&client, &notification_node),
             "should_ack must still return TRUE for <notification> stanzas."
         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3698 - 3731, The tests call
client.should_ack(&receipt_node) and &notification_node but should_ack now
expects &NodeRef; update the test to create OwnedNodeRef instances from the
existing Node values (marshal/encode each Node into bytes and construct an
OwnedNodeRef), then call client.should_ack(owned_ref.get()) for both receipt and
notification nodes and assert the results as before; reference symbols:
should_ack, OwnedNodeRef, NodeRef, client, receipt_node, notification_node.

3157-3178: ⚠️ Potential issue | 🔴 Critical

Multiple compilation errors in handle_iq: attrs indexing and downstream call.

  1. node.attrs.get("type") doesn't work on NodeRef slice — use get_attr("type")
  2. node.attrs.get("xmlns") — same issue
  3. node.attrs() call — use attr_parser()
  4. pair::handle_iq(self, node) expects &Node but receives &NodeRef — the pair module needs updating too
Proposed fix
     pub(crate) async fn handle_iq(self: &Arc<Self>, node: &wacore_binary::NodeRef<'_>) -> bool {
-        if node.attrs.get("type").is_some_and(|s| s == "get")
+        if node.get_attr("type").is_some_and(|s| s == "get")
             && (node.get_optional_child("ping").is_some()
                 || node
-                    .attrs
-                    .get("xmlns")
+                    .get_attr("xmlns")
                     .is_some_and(|s| s == "urn:xmpp:ping"))
         {
             info!("Received ping, sending pong.");
-            let mut parser = node.attrs();
+            let mut parser = node.attr_parser();
             let from_jid = parser.jid("from");
             let id = parser.optional_string("id").map(|s| s.to_string());
             let pong = build_pong(from_jid.to_string(), id.as_deref());
             if let Err(e) = self.send_node(pong).await {
                 warn!("Failed to send pong: {e:?}");
             }
             return true;
         }
 
         // Pass Node directly to pair handling
-        if pair::handle_iq(self, node).await {
+        if pair::handle_iq(self, &node.to_owned_node()).await {
             return true;
         }

Note: Converting to owned node for pair::handle_iq is a workaround. Ideally, pair::handle_iq should also be updated to accept &NodeRef.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3157 - 3178, In handle_iq replace the incorrect
attrs accessors: use node.get_attr("type") and node.get_attr("xmlns") instead of
node.attrs.get(...), and replace node.attrs() with node.attr_parser() when
building the parser used to extract from_jid and id (so keep
build_pong(from_jid.to_string(), id.as_deref()) and send_node usage unchanged).
For the pair::handle_iq call either convert the NodeRef into an owned Node
before calling pair::handle_iq(self, owned_node).await or update the
pair::handle_iq signature to accept &wacore_binary::NodeRef<'_> so the call
matches; ensure the chosen approach is applied consistently in this module and
the pair module (referencing handle_iq, build_pong, attr_parser, and
pair::handle_iq).

4887-4959: ⚠️ Potential issue | 🔴 Critical

Test compilation errors: process_node signature changed.

Multiple tests (e.g., test_ib_thread_metadata_does_not_end_sync, test_ib_offline_child_ends_sync) call client.process_node(Arc::new(node)) where node is a Node, but process_node now expects Arc<OwnedNodeRef>.

Proposed fix

Create a helper function or update each test:

+    /// Helper to convert Node to OwnedNodeRef for testing
+    fn node_to_owned_ref(node: Node) -> wacore_binary::OwnedNodeRef {
+        let bytes = wacore_binary::marshal::marshal_auto(&node).unwrap();
+        wacore_binary::OwnedNodeRef::new(bytes).unwrap()
+    }
+
     #[tokio::test]
     async fn test_ib_thread_metadata_does_not_end_sync() {
         let client = create_offline_sync_test_client().await;
         client
             .offline_sync_metrics
             .active
             .store(true, Ordering::Release);
 
         let node = NodeBuilder::new("ib")
             .children([NodeBuilder::new("thread_metadata")
                 .children([NodeBuilder::new("item").build()])
                 .build()])
             .build();
 
-        client.process_node(Arc::new(node)).await;
+        client.process_node(Arc::new(node_to_owned_ref(node))).await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4887 - 4959, Tests call
client.process_node(Arc::new(node)) but process_node now expects
Arc<OwnedNodeRef>, so update the tests (e.g., in
test_ib_thread_metadata_does_not_end_sync,
test_ib_edge_routing_does_not_end_sync, test_ib_dirty_does_not_end_sync,
test_ib_offline_child_ends_sync) to convert the Node built by NodeBuilder into
an OwnedNodeRef before wrapping in Arc; for example, construct the Node with
NodeBuilder, convert it to an OwnedNodeRef (using the crate’s conversion API
such as OwnedNodeRef::from(node) or the provided Node->OwnedNodeRef
constructor), then call client.process_node(Arc::new(owned_node_ref)). Ensure
references to process_node, OwnedNodeRef, NodeBuilder, and
create_offline_sync_test_client are updated accordingly.

3070-3078: ⚠️ Potential issue | 🔴 Critical

Compilation error: DisplayableNode expects &Node, not &NodeRef.

Multiple locations use DisplayableNode(node) but node is now &NodeRef. The code already imports DisplayableNodeRef at line 1528 — use that instead. Also, StreamError.raw expects Option<Node>, not NodeRef.

Proposed fix
-                _ => {
-                    error!("Unknown stream error: {}", DisplayableNode(node));
+                _ => {
+                    error!("Unknown stream error: {}", DisplayableNodeRef(node));
                     self.expected_disconnect.store(true, Ordering::Relaxed);
                     self.core.event_bus.dispatch(&Event::StreamError(
                         crate::types::events::StreamError {
                             code: code.to_string(),
-                            raw: Some(node.clone()),
+                            raw: Some(node.to_owned_node()),
                         },
                     ));
                 }

Apply similar changes at lines 3129, 3142, and 3151.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3070 - 3078, Replace uses of
DisplayableNode(node) with DisplayableNodeRef(node) because node is a &NodeRef;
also ensure StreamError.raw is given an owned Node by converting the NodeRef
(e.g., Some(node.to_owned()) or the appropriate to_owned/clone API) instead of
passing the NodeRef directly. Update the block creating
crate::types::events::StreamError (and the other similar sites using
DisplayableNode(node)) to use DisplayableNodeRef and to pass raw:
Some(node.to_owned()) so the type matches Option<Node>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 3265-3267: The call here passes a &wacore_binary::NodeRef but
unified_session.update_server_time_offset expects a &Node, so either change
UnifiedSessionManager::update_server_time_offset to accept
&wacore_binary::NodeRef<'_> (update its signature and usages) or convert the
NodeRef to the owned Node before calling
unified_session.update_server_time_offset (e.g., obtain an owned Node via the
appropriate conversion/clone method on NodeRef and pass a &Node). Target
symbols: update_server_time_offset (in this file),
unified_session.update_server_time_offset,
UnifiedSessionManager::update_server_time_offset, and wacore_binary::NodeRef.
- Around line 1687-1693: The failure comes from a mismatch between should_ack's
signature and how it's used: should_ack currently takes
&wacore_binary::NodeRef<'_> (function should_ack) but callers/tests pass &Node;
either change should_ack to accept &wacore_binary::Node (if that API is easier)
or update the callers/tests to pass a NodeRef by converting the Node to a
NodeRef (e.g. node.as_ref() / node.to_ref() / Node::as_ref() depending on the
crate API) and ensure attribute checks use the correct accessor for NodeRef
(e.g. get_attr(...) -> Option<&ValueRef> or attr(...) as provided by the
library); pick one consistent approach and update all usages/tests to match
should_ack and the Node/NodeRef attribute accessor.

In `@src/client/device_registry.rs`:
- Around line 307-313: The code silently downgrades failed Server conversion to
Server::Pn causing LID sessions to be skipped; replace the
try_from(...).unwrap_or(Server::Pn) fallback by iterating the Server enum
variants explicitly (so both LID and PN are handled) when building the Jid
inside the loops; locate the loop using variables servers, lookup.all_keys(),
device_ids and the conversion call wacore_binary::jid::Server::try_from(srv) /
Server::Pn and change it to enumerate the actual Server variants (or otherwise
obtain both relevant Server variants) before calling Jid::new and setting
jid.device so delete_sessions_for_devices() will run for each server variant
rather than masking conversion failures.

In `@src/handlers/chatstate.rs`:
- Around line 57-58: The handler is passing an Arc<OwnedNodeRef> to
ChatstateStanza::parse which expects &Node; change the call to pass the
converted node by invoking to_owned_node() on the Arc (e.g., let owned =
node.to_owned_node(); then call ChatstateStanza::parse(&owned)) so parse
receives a &Node instead of &Arc<OwnedNodeRef>.

In `@src/handlers/ib.rs`:
- Around line 29-30: The helper handle_ib_impl must be migrated from taking
&Node to the zero-copy type: change its signature to accept
Arc<wacore_binary::OwnedNodeRef> (e.g. async fn handle_ib_impl(client:
Arc<Client>, node: Arc<wacore_binary::OwnedNodeRef>) -> ...), update any
internal code in handle_ib_impl that references Node to use
wacore_binary::OwnedNodeRef, and update the call sites in this file (currently
calling handle_ib_impl(client, &node)) to pass the Arc (e.g.
handle_ib_impl(client, node.clone()) or handle_ib_impl(client, node) as
appropriate). Also adjust imports to bring wacore_binary::OwnedNodeRef into
scope.

In `@src/handlers/notification.rs`:
- Around line 37-39: The handler method handle currently passes an
Arc<wacore_binary::OwnedNodeRef> into handle_notification_impl which expects a
&Node; convert the yoke-backed stanza to an owned Node at the boundary by
calling the to_owned_node() method on the OwnedNodeRef before awaiting
handle_notification_impl. Locate the handle function and change the argument
from &node to a temporary owned_node produced via node.to_owned_node() (or
similar) and pass &owned_node into handle_notification_impl so types match.

In `@src/handlers/receipt.rs`:
- Around line 23-24: The handler currently passes
Arc<wacore_binary::OwnedNodeRef> (named node) into client.handle_receipt, but
Client::handle_receipt expects Arc<Node>, causing the E0308; fix by converting
the OwnedNodeRef to the expected Arc<Node> before calling client.handle_receipt
(or alternatively change Client::handle_receipt to accept
Arc<wacore_binary::OwnedNodeRef> to finish the migration). Locate the async fn
handle in src/handlers/receipt.rs and either (a) transform node into an
Arc<Node> (using the appropriate conversion/constructor provided by
wacore_binary, e.g., a from/into/to_node helper) and pass that to
client.handle_receipt, or (b) update the signature of Client::handle_receipt to
accept Arc<wacore_binary::OwnedNodeRef> and adjust its implementation
accordingly so types match.

In `@src/handlers/router.rs`:
- Around line 49-56: Tests and MockHandler need to be updated to use the new
zero-copy OwnedNodeRef type: change the MockHandler impl and test fixtures that
currently accept Arc<Node> to accept Arc<wacore_binary::OwnedNodeRef> (so they
match dispatch signature and handlers.get(...).handle(client, node,
cancelled).await), and update the dispatch tests to construct and pass
Arc<OwnedNodeRef> instances instead of Arc<Node> when calling Router::dispatch;
ensure any pattern matching or field access in MockHandler uses node.get() like
the production code.

In `@src/handlers/unimplemented.rs`:
- Around line 46-47: In handle (async fn handle) replace direct access to
node.tag with the owned node's borrowed NodeRef: call node.get() to obtain the
NodeRef, then read the tag via node_ref.tag.as_ref() and pass that &str into
client.handle_unimplemented(...). Concretely, inside handle use let nr =
node.get(); then await client.handle_unimplemented(nr.tag.as_ref()).await so you
respect OwnedNodeRef's API and convert the Cow<str> to &str.

In `@wacore/binary/src/decoder.rs`:
- Around line 111-114: The code currently coerces an invalid JID server into
Server::Pn; instead, when crate::jid::Server::try_from(server_str.as_ref())
fails (i.e., server_str is missing/unknown), return or propagate a decoding
error rather than defaulting to Server::Pn. Locate the server parsing in
decoder.rs (the read_value_as_string()/server_str and Server::try_from usage)
and replace the unwrap_or(...Server::Pn) path with proper error handling: map
the try_from failure into the decoder's Result error (or use ? to propagate) so
malformed JID_PAIR server values fail fast.

In `@wacore/binary/src/jid.rs`:
- Around line 296-298: The is_ad method currently checks server() against
Server::Pn, Server::Lid, and Server::Hosted but omits Server::HostedLid, causing
`@hosted.lid` device JIDs to be misclassified; update the match in fn is_ad(&self)
-> bool (and any similar checks) to include Server::HostedLid alongside Pn, Lid,
and Hosted so hosted-LID device JIDs are recognized as AD.

In `@wacore/src/iq/privacy.rs`:
- Line 638: The test fixture currently uses a phone-like PN JID via pn_jid:
Some(Jid::new("5511999999999", Server::Pn)) which appears real; update the
fixture to use an obviously fictitious/reserved identifier (e.g., a clearly fake
phone/JID string) when constructing the pn_jid with Jid::new and keep Server::Pn
unchanged so tests avoid real PII—locate the pn_jid field in
wacore/src/iq/privacy.rs and replace the literal with a clearly fake value.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 3100-3105: In handle_connect_failure replace the call to
node.attrs() with node.attr_parser() (same fix as handle_stream_error): obtain
the attribute parser via let mut attrs = node.attr_parser(); and then use
attrs.optional_u64("reason").unwrap_or(0) as i32 to read the reason code,
ensuring the parser API is used instead of the non-existent attrs() method.
- Around line 1412-1416: The code attempts to access node.tag on an OwnedNodeRef
returned by decrypt_frame, causing a compilation error; update the matches call
to use node.get().tag (i.e., call .get() on the OwnedNodeRef before inspecting
tag) so process_inline is computed from node.get().tag, ensuring you dereference
the OwnedNodeRef returned by decrypt_frame when checking tags like "success" |
"failure" | "stream:error" | "message" | "ib".
- Around line 1926-1941: The code attempts to index node.attrs with get("lid")
which fails because NodeRef.attrs is a slice of (Cow<str>, ValueRef) pairs;
change the extraction in the lid_from_server block to use the NodeRef API or
iterate the slice: call node.get_attr("lid") if available or iterate node.attrs
to find the tuple whose 0 equals "lid", then convert its ValueRef to_jid() as
before; update the lid_from_server match to use that retrieved attribute (refer
to symbols: node.attrs, node.get_attr, lid_from_server,
self.update_server_time_offset) and preserve the same logging paths when
attribute is missing or parsing fails.
- Around line 1632-1651: The code currently passes an Arc<OwnedNodeRef>
(variable node) to places expecting Arc<Node>, causing type mismatches; update
the types to accept OwnedNodeRef to preserve zero-copy: change Event::RawNode
variant, the NodeWaiter type and resolve_node_waiters(&node) signature to accept
Arc<OwnedNodeRef> (or NodeRef/OwnedNodeRef as used in your codebase), update
NodeFilter::matches to take a NodeRef/OwnedNodeRef instead of Arc<Node>, and
update maybe_deferred_ack and send_ack_for signatures/usages to accept
Arc<OwnedNodeRef> (converting to owned Node only where strictly necessary).
Ensure all call sites that dispatch Event::RawNode, invoke resolve_node_waiters,
call NodeFilter::matches, and call maybe_deferred_ack/send_ack_for are updated
accordingly to the new OwnedNodeRef-based types.
- Around line 2982-2997: The compile error occurs because wacore_binary::NodeRef
has no attrs() method; update handle_stream_error to use node.attr_parser() (or
access the attr_parser field) instead: replace let mut attrs = node.attrs() with
let mut parser = node.attr_parser(); then get the code via
parser.optional_string("code").as_deref().unwrap_or(""); similarly, when mapping
the "conflict" child, call
n.attr_parser().optional_string("type").as_deref().unwrap_or("").to_string() so
all attribute parsing uses AttrParser methods instead of attrs(); keep existing
variable names (code_cow/code and conflict_type) and behavior otherwise.
- Around line 3698-3731: The tests call client.should_ack(&receipt_node) and
&notification_node but should_ack now expects &NodeRef; update the test to
create OwnedNodeRef instances from the existing Node values (marshal/encode each
Node into bytes and construct an OwnedNodeRef), then call
client.should_ack(owned_ref.get()) for both receipt and notification nodes and
assert the results as before; reference symbols: should_ack, OwnedNodeRef,
NodeRef, client, receipt_node, notification_node.
- Around line 3157-3178: In handle_iq replace the incorrect attrs accessors: use
node.get_attr("type") and node.get_attr("xmlns") instead of node.attrs.get(...),
and replace node.attrs() with node.attr_parser() when building the parser used
to extract from_jid and id (so keep build_pong(from_jid.to_string(),
id.as_deref()) and send_node usage unchanged). For the pair::handle_iq call
either convert the NodeRef into an owned Node before calling
pair::handle_iq(self, owned_node).await or update the pair::handle_iq signature
to accept &wacore_binary::NodeRef<'_> so the call matches; ensure the chosen
approach is applied consistently in this module and the pair module (referencing
handle_iq, build_pong, attr_parser, and pair::handle_iq).
- Around line 4887-4959: Tests call client.process_node(Arc::new(node)) but
process_node now expects Arc<OwnedNodeRef>, so update the tests (e.g., in
test_ib_thread_metadata_does_not_end_sync,
test_ib_edge_routing_does_not_end_sync, test_ib_dirty_does_not_end_sync,
test_ib_offline_child_ends_sync) to convert the Node built by NodeBuilder into
an OwnedNodeRef before wrapping in Arc; for example, construct the Node with
NodeBuilder, convert it to an OwnedNodeRef (using the crate’s conversion API
such as OwnedNodeRef::from(node) or the provided Node->OwnedNodeRef
constructor), then call client.process_node(Arc::new(owned_node_ref)). Ensure
references to process_node, OwnedNodeRef, NodeBuilder, and
create_offline_sync_test_client are updated accordingly.
- Around line 3070-3078: Replace uses of DisplayableNode(node) with
DisplayableNodeRef(node) because node is a &NodeRef; also ensure StreamError.raw
is given an owned Node by converting the NodeRef (e.g., Some(node.to_owned()) or
the appropriate to_owned/clone API) instead of passing the NodeRef directly.
Update the block creating crate::types::events::StreamError (and the other
similar sites using DisplayableNode(node)) to use DisplayableNodeRef and to pass
raw: Some(node.to_owned()) so the type matches Option<Node>.

In `@src/handlers/iq.rs`:
- Around line 26-34: The handler is treating Arc<OwnedNodeRef> as a Node/NodeRef
causing type errors; explicitly dereference the Arc<OwnedNodeRef> to a &NodeRef
before calling helper APIs. Inside handle(), bind a reference like let node_ref
= node.as_ref() or &*node and then call client.handle_iq(node_ref). Use
node_ref.attrs.get(...) for attributes and pass node_ref to DisplayableNode (or
use DisplayableNodeRef(node_ref) as suggested) so all three places (handle_iq,
attrs.get, DisplayableNode) receive the correct referenced type.

In `@src/handlers/message.rs`:
- Around line 32-113: The handler mixes OwnedNodeRef and Arc<Node>: replace
direct calls to node.attrs() with node.get().attrs() to access the inner
NodeRef, and when enqueuing convert the OwnedNodeRef into the queue's Arc<Node>
type (e.g., wrap the owned node: Arc::new(node.to_owned_node()) or whatever
conversion method exists) before calling tx.try_send; update the code paths
around handle, node.get(), attrs(), message_queues and tx.try_send to perform
these swaps so types align.

In `@src/handlers/presence.rs`:
- Around line 24-40: The handler uses Arc<wacore_binary::OwnedNodeRef>
(parameter name node) as if it exposes attrs directly, causing E0609; change
accesses to call node.get() to obtain the borrowed NodeRef and then use NodeRef
methods (e.g., get_attr("from") / get_attr("type") / get_attr("last") or access
the .attrs on the borrowed NodeRef) when parsing from_jid, unavailable, and
last_seen inside async fn handle; update all occurrences referencing node.attrs
to use node.get().get_attr(...) (or node.get().attrs) and adjust the last_seen
parsing to parse the string from the borrowed attribute value accordingly.

In `@wacore/binary/src/decoder.rs`:
- Around line 131-144: The decoder currently rejects unknown agent values for
AD_JID, breaking encode/decode symmetry; change the fallback so unmapped agent
bytes are treated as PN (preserving agent-coded PN AD_JID) instead of returning
an error. In the match on `agent` (the AD_JID domain type parsing) return
crate::jid::Server::Pn for the default/unknown-arm (or otherwise accept and map
unmapped `agent` values to `Server::Pn`) so values emitted by
`server_to_domain_type()` round-trip instead of erroring.

In `@wacore/binary/src/jid.rs`:
- Around line 705-710: The Display implementations for JID append ".{agent}"
when self.agent > 0 but currently skip only Server::Pn, Server::Lid, and
Server::Hosted; update the condition to also skip Server::HostedLid so the agent
is not appended for hosted-lid addresses (preventing outputs like
user.<agent>:<device>@hosted.lid); locate the branches that check
matches!(self.server, Server::Pn | Server::Lid | Server::Hosted) in the Display
impl(s) (the blocks that call write!(f, ".{}", self.agent)?), add
Server::HostedLid to that matches! list in both places (the other occurrence
around the 723-731 range) so hosted-lid follows the same suppression logic.
- Around line 652-685: The early branch that handles dotted-agent when server !=
DEFAULT_USER_SERVER && server != HIDDEN_USER_SERVER (the if let Some((u,
last_part)) = user.rsplit_once('.') && let Ok(num_val) =
last_part.parse::<u16>() block) currently assigns agent = num_val as u8 without
range-checking and can truncate values; add the same bounds check used in the
later fallback branch (if num_val > u8::MAX as u16 { return
Err(JidError::InvalidFormat(format!("Agent component out of range:
{num_val}"))); }) before casting, and keep the assignments user = u and agent =
num_val as u8 only after the check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 639eff63-3fd9-4b0c-93cf-868532187e07

📥 Commits

Reviewing files that changed from the base of the PR and between 0c24b5e and f457025.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (45)
  • Cargo.toml
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/message.rs
  • src/pair_code.rs
  • src/request.rs
  • src/send.rs
  • src/usync.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/encoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/prekeys.rs
  • wacore/src/types/jid.rs
  • wacore/tests/jid_test.rs

Comment thread src/client.rs
Comment thread src/client.rs
Comment thread src/client/device_registry.rs Outdated
Comment thread src/handlers/chatstate.rs Outdated
Comment thread src/handlers/ib.rs Outdated
Comment thread src/handlers/router.rs
Comment thread src/handlers/unimplemented.rs Outdated
Comment thread wacore/binary/src/decoder.rs Outdated
Comment thread wacore/binary/src/jid.rs
Comment thread wacore/src/iq/privacy.rs Outdated
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from f457025 to 2b4beb4 Compare April 11, 2026 14:21
@jlucaso1 jlucaso1 changed the title Perf/yoke zero copy and server enum perf!: yoke zero-copy node decoding and Jid Server enum Apr 11, 2026
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from 2b4beb4 to ab548ea Compare April 11, 2026 14:25
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review, check if all issues pointed out by reviews are solved, check for more bugs, inconsistencies, and more performance improvement opportunities, please do a careful and deep review

@claude

claude Bot commented Apr 11, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 9m 51s —— View job


Deep Review: Yoke Zero-Copy & Server Enum PR

  • Verify all previously reported issues are resolved
  • Review Server enum implementation
  • Review Yoke/OwnedNodeRef implementation
  • Review decoder/encoder changes
  • Review handler chain migration
  • Review client-side changes
  • Review supporting changes
  • Check for additional bugs, inconsistencies, regressions
  • Check for further performance improvement opportunities
  • Final summary

Previously Reported Issues — Resolution Status

All issues flagged by CodeRabbit and Codex in the previous round have been addressed:

Issue Status
is_ad() missing Server::HostedLid Fixed (jid.rs:296-301)
Display impls suppress agent for HostedLid Fixed (jid.rs:718-721, 742-746)
Agent u16 as u8 truncation Fixed — bounds check added (jid.rs:666-669, 683-686)
JID_PAIR decoder silently defaults to Server::Pn Fixed — now returns BinaryError::AttrParse (decoder.rs:113-115)
AD_JID domain type preserve agent-coded PN Fixed — raw agent byte preserved in struct (decoder.rs:148)
device_registry.rs iterates Server::Lid/Server::Pn directly Fixed (device_registry.rs:303-306)
privacy.rs test phone number Fixed — uses "15550001111" now
All handler handle() signatures → Arc<OwnedNodeRef> Fixed — all 11 handlers updated
client.rs compilation errors (handle_iq, handle_success, etc.) Fixed — all use correct NodeRef APIs
Router tests updated FixedMockHandler and tests use OwnedNodeRef
DisplayableNode vs DisplayableNodeRef Fixed — all uses match the type they receive

New Findings

Bug: Redundant fallback parser block (jid.rs:675-690)

The FromStr fallback path has two consecutive blocks that both parse dot-separated suffixes:

  • Block 1 (lines 661-673): Correctly handles non-default/non-LID servers — parses agent from user (already colon-stripped).
  • Block 2 (lines 675-690): Re-parses from user_part (the original string, not the colon-stripped user). This block runs unconditionally for ALL servers.

For non-default servers, Block 2 re-parses from user_part and overwrites the user and agent that Block 1 already correctly set. For most real inputs this is incidentally safe (the results happen to match), but for edge cases where user_part contains :device in a dot segment, Block 2 can clobber Block 1's correct values.

For DEFAULT_USER_SERVER, the Block 2 device = num_val path is dead code because DEFAULT_USER_SERVER JIDs with dots are fully handled by parse_jid_fast (which returns before reaching the fallback), and server-only JIDs (s.whatsapp.net without @) are handled at lines 622-632.

Recommendation: Remove Block 2 entirely — it is redundant and a latent bug source. Fix this →

Performance: to_owned_node() in hot paths defeats zero-copy

The yoke migration is architecturally sound, but several hot-path handlers still call to_owned_node(), performing a full deep clone that negates the zero-copy benefit:

Location Handler Severity Why
src/handlers/message.rs:117 MessageHandler High Every incoming message is deep-cloned. Channel type is still Sender<Arc<Node>> (line 70)
src/handlers/receipt.rs:28 ReceiptHandler Medium Every receipt: Arc::new(node.to_owned_node()) — creates a redundant Arc too
src/handlers/notification.rs:43 NotificationHandler Medium Every notification deep-cloned
src/client.rs:113 resolve_waiters Low Only when IQ waiters are registered
src/client.rs:1661 response_waiters Low Only for IQ responses
src/client.rs:1637 Event::RawNode Low Gated by raw_node_forwarding flag (default off)

The root cause is that downstream consumers (handle_incoming_message, handle_receipt, handle_notification_impl, NodeWaiter, Event::RawNode) still accept Node/Arc<Node> rather than OwnedNodeRef/NodeRef. Migrating these in a follow-up would unlock the full zero-copy benefit.

Highest-impact follow-up: Change message_queues channel type from Sender<Arc<Node>> to Sender<Arc<OwnedNodeRef>> and update handle_incoming_message to accept Arc<OwnedNodeRef>. This would eliminate the deepest allocation on the hottest path.

Performance: Redundant Server::try_from in production code (lid_pn.rs:150-151)

server: wacore_binary::jid::Server::try_from(lid_server)
    .unwrap_or(wacore_binary::jid::Server::Lid),

lid_server is the constant "lid" which always maps to Server::Lid. This try_from + unwrap_or is unnecessary runtime overhead in production code. Should just be server: Server::Lid. Same pattern appears 6 times in test code (src/message.rs:3585-3819) where it's less critical but still noisy. Fix this →

Inconsistency: Mixed string-constant vs enum comparisons

Several files still compare jid.server against string constants (DEFAULT_USER_SERVER, HIDDEN_USER_SERVER) via the PartialEq<str> impl, instead of using the Server enum directly. This works correctly but is inconsistent with the PR's goal:

  • src/message.rs:1310-1324sender.server == lid_server / sender.server == pn_server
  • src/client/lid_pn.rs:139-145 — same pattern
  • wacore/src/prekeys.rs:113-114jid.server == DEFAULT_USER_SERVER

These should use sender.server == Server::Lid or sender.is_lid() for type safety and clarity. Not a bug, but a missed migration opportunity.

Minor: is_empty() only checks user (jid.rs:342-344)

Codex flagged this as a potential regression. After review, this is not a regression — with the Server enum, there's no concept of an "empty server" (it always defaults to Server::Pn). Any Jid with user="" is semantically "empty" (a server-only JID). The build_iq_node target emission gated on !target.is_empty() still works correctly because server-only targets like Jid::new("", Server::Pn) will correctly report as empty and won't emit a redundant target attribute.


Architecture Observations

Yoke design is sound

The OwnedNodeRef wrapping Yoke<NodeRef<'static>, Vec<u8>> is correct:

  • NodeRef, ValueRef, NodeContentRef, JidRef all derive Yokeable
  • Vec<u8> implements StableDeref (required by yoke)
  • OwnedNodeRef is Send + Sync (required for Arc sharing across async tasks)
  • get() returns &NodeRef<'_> with correctly elided lifetime

Handler migration is well-structured

The tier approach is clear:

  • Fully zero-copy (no to_owned_node()): iq.rs, presence.rs, unimplemented.rs, basic.rs (Success/Failure/StreamError)
  • Bridge layer (to_owned_node() at boundary): message.rs, receipt.rs, notification.rs, chatstate.rs, ib.rs, basic.rs (Ack)

send_ack_for is fully zero-copy

The ack path (maybe_deferred_acksend_ack_forbuild_ack_node) now takes &NodeRef throughout (client.rs:1700-1728). The Arc<OwnedNodeRef> is moved into the spawned async closure, keeping the buffer alive without cloning. This is the right pattern.


Summary

The PR is well-designed and nearly complete. All previously reported issues are resolved. The Server enum change is clean and complete. The yoke integration is architecturally sound with correct usage patterns.

Should fix before merge:

  1. Remove redundant Block 2 in JID fallback parser (jid.rs:675-690) — latent bug
  2. Replace Server::try_from(lid_server).unwrap_or(Server::Lid) with Server::Lid in production code (lid_pn.rs:150-151)

Nice to have (follow-up):

  • Migrate message_queues channel to Sender<Arc<OwnedNodeRef>> for full zero-copy on the hottest path
  • Replace string-constant server comparisons with enum comparisons across message.rs, lid_pn.rs, prekeys.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
wacore/binary/src/decoder.rs (1)

109-184: 🧹 Nitpick | 🔵 Trivial

Add regression coverage for the new server-decoding branches.

read_jid_pair, read_ad_jid, read_interop_jid, and read_fb_jid now all have distinct malformed-server behavior, but this module still has no tests for any of those paths. A small table-driven set here would make the enum migration much safer to evolve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 109 - 184, Tests are missing for
the new server-decoding branches; add table-driven unit tests that exercise
malformed and valid-server paths for read_jid_pair, read_ad_jid,
read_interop_jid, and read_fb_jid. For each function create inputs that produce:
successful JidRef (when server string matches expected), InvalidNode (when
missing/empty user or wrong interop/messenger server), and AttrParse (for
read_jid_pair when server string fails Server::try_from), assert the returned
Result matches BinaryError::InvalidNode or BinaryError::AttrParse or the
expected JidRef fields; reference the decoder methods (read_jid_pair,
read_ad_jid, read_interop_jid, read_fb_jid) and constants
crate::jid::INTEROP_SERVER and crate::jid::MESSENGER_SERVER to build the test
cases so regressions in server-decoding are caught.
src/client.rs (1)

96-114: 🧹 Nitpick | 🔵 Trivial

Broadcast the owned waiter payload instead of re-cloning it per waiter.

node.to_owned_node() is currently called once for every matching waiter. That turns a single incoming stanza into O(matches) deep clones right in the resolve path, which undercuts the zero-copy work when multiple waiters observe the same node.

♻️ Proposed fix
 fn resolve_waiters(
     waiters_mutex: &std::sync::Mutex<Vec<NodeWaiter>>,
     counter: &AtomicUsize,
     node: &Arc<wacore_binary::OwnedNodeRef>,
 ) {
     let nr = node.get();
+    let mut shared_owned: Option<Arc<Node>> = None;
     let mut waiters = waiters_mutex
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
     let mut i = 0;
     while i < waiters.len() {
@@
         } else if waiters[i].filter.matches(nr) {
             let w = waiters.swap_remove(i);
             counter.fetch_sub(1, Ordering::Release);
-            let _ = w.tx.send(Arc::new(node.to_owned_node()));
+            let owned = shared_owned
+                .get_or_insert_with(|| Arc::new(node.to_owned_node()))
+                .clone();
+            let _ = w.tx.send(owned);
         } else {
             i += 1;
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 96 - 114, In resolve_waiters: avoid calling
node.to_owned_node() for every matching waiter; create a single Arc-owned
payload (e.g., let owned = Arc::new(node.to_owned_node())) the first time you
hit a matching waiter and then send Arc::clone(&owned) to each subsequent waiter
(use Arc::clone or owned.clone()) instead of Arc::new(node.to_owned_node());
this keeps the existing swap_remove/counter.fetch_sub logic but replaces
per-waiter deep clones with cheap Arc clones to broadcast the same owned
payload.
♻️ Duplicate comments (1)
src/handlers/ib.rs (1)

29-37: ⚠️ Potential issue | 🟠 Major

Avoid re-materializing Node in the handler hot path.

Line 35 (to_owned_node()) reintroduces a full clone/allocation per <ib> stanza and undermines the zero-copy migration.

♻️ Proposed zero-copy adjustment
-use wacore_binary::node::{Node, NodeContent};
+use wacore_binary::node::NodeContent;

 impl StanzaHandler for IbHandler {
@@
     async fn handle(
         &self,
         client: Arc<Client>,
         node: Arc<wacore_binary::OwnedNodeRef>,
         _cancelled: &mut bool,
     ) -> bool {
-        let owned = node.to_owned_node();
-        handle_ib_impl(client, &owned).await;
+        handle_ib_impl(client, node.as_ref()).await;
         true
     }
 }

-async fn handle_ib_impl(client: Arc<Client>, node: &Node) {
-    for child in node.children().unwrap_or_default() {
+async fn handle_ib_impl(client: Arc<Client>, node: &wacore_binary::OwnedNodeRef) {
+    let node = node.get();
+    for child in node.children().unwrap_or_default() {
         match child.tag.as_ref() {
             // unchanged
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 29 - 37, The handler currently clones the
Node with node.to_owned_node(), which allocates per stanza; instead change
handle_ib_impl's signature to take a borrowed OwnedNodeRef (e.g.,
&wacore_binary::OwnedNodeRef) and call it with a reference to the existing
Arc-held node (for example handle_ib_impl(client, &*node).await or
handle_ib_impl(client, node.as_ref()).await) so no full Node is re-materialized;
update the handle_ib_impl function name/signature and all its call sites
accordingly (replace uses expecting owned Node with borrowed accessors).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 3182-3184: The code currently clones every incoming Node via
node.to_owned() before calling pair::handle_iq, which is expensive on the hot IQ
path; either change pair::handle_iq to accept a non-owning NodeRef/OwnedNodeRef
(e.g., update pair::handle_iq signature to take &NodeRef or OwnedNodeRef and
adjust pair.rs to work with that) so no clone is required for non-pair stanzas,
or add a cheap pre-check (inspect NodeRef metadata such as node.tag/name,
namespace, or presence of a known pair-specific child/attribute) before calling
node.to_owned() and only materialize the owned Node when the pre-check indicates
the stanza might be for pairing. Ensure you update calls and tests that invoke
pair::handle_iq to match the new signature if you choose the first option.
- Around line 4885-4892: The test helper node_to_owned_ref currently strips the
leading format byte by slicing bytes[1..], which hardcodes framing; update it to
use the same unpack logic as production by calling wacore_binary::util::unpack
(or the equivalent helper used by decrypt_frame) on the marshalled bytes and
pass the returned payload into OwnedNodeRef::new so tests mirror production
unpack/compression handling; locate node_to_owned_ref and replace the bytes[1..]
slicing with the unpack call and use its output for OwnedNodeRef::new.

In `@src/handlers/basic.rs`:
- Around line 84-91: The current handler calls OwnedNodeRef::to_owned_node(),
which clones the entire node and defeats the allocation savings—change
Client::handle_ack_response to accept a borrowed &wacore_binary::NodeRef<'_> and
call client.handle_ack_response(node.get()). Specifically, update the signature
of Client::handle_ack_response to take &wacore_binary::NodeRef<'_> and move the
ack-parsing logic into that borrowed form, then replace the to_owned_node() call
in handle with passing node.get() so no full clone is performed.

In `@src/handlers/receipt.rs`:
- Around line 22-30: The current call creates a new Owned Node and Arc every
receipt: Arc::new(node.to_owned_node()) inside handle should be replaced by
updating Client::handle_receipt's signature to avoid that allocation; change
Client::handle_receipt to accept either a reference (&Node or &OwnedNodeRef) if
it only needs read access, then call it with node.as_ref() or &*node (no
allocation), or change it to accept Arc<OwnedNodeRef> and call it with
Arc::clone(&node) to reuse the existing Arc; update the Client::handle_receipt
definition and all callers (and their imports) accordingly to match the new
parameter type.

In `@wacore/binary/src/jid.rs`:
- Around line 627-631: The branch that returns Err(JidError::InvalidFormat(...))
is constructing an error string that already includes the "Invalid JID format:"
prefix, causing duplication because JidError's Display impl adds that prefix;
change the returned InvalidFormat payload to just the server identifier or a
message without the "Invalid JID format:" prefix (e.g., server.to_string() or a
concise explanation like "unknown server '{server}'") so that
JidError::InvalidFormat and its Display produce a single prefixed message;
locate the return in the conditional around user_part.is_empty() and
Server::try_from(server).
- Around line 385-390: Call sites passing a &str to Jid::new must be updated to
supply a Server instance instead; find all uses of Jid::new(..., "...") and
replace the string literal with a Server constructed from that string (e.g.
Server::from("example.com"), Server::new("example.com"), or
Server::try_from("example.com").unwrap(), depending on which constructor/trait
the Server type exposes), so that every Jid::new invocation provides a Server
rather than a &str.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 96-114: In resolve_waiters: avoid calling node.to_owned_node() for
every matching waiter; create a single Arc-owned payload (e.g., let owned =
Arc::new(node.to_owned_node())) the first time you hit a matching waiter and
then send Arc::clone(&owned) to each subsequent waiter (use Arc::clone or
owned.clone()) instead of Arc::new(node.to_owned_node()); this keeps the
existing swap_remove/counter.fetch_sub logic but replaces per-waiter deep clones
with cheap Arc clones to broadcast the same owned payload.

In `@wacore/binary/src/decoder.rs`:
- Around line 109-184: Tests are missing for the new server-decoding branches;
add table-driven unit tests that exercise malformed and valid-server paths for
read_jid_pair, read_ad_jid, read_interop_jid, and read_fb_jid. For each function
create inputs that produce: successful JidRef (when server string matches
expected), InvalidNode (when missing/empty user or wrong interop/messenger
server), and AttrParse (for read_jid_pair when server string fails
Server::try_from), assert the returned Result matches BinaryError::InvalidNode
or BinaryError::AttrParse or the expected JidRef fields; reference the decoder
methods (read_jid_pair, read_ad_jid, read_interop_jid, read_fb_jid) and
constants crate::jid::INTEROP_SERVER and crate::jid::MESSENGER_SERVER to build
the test cases so regressions in server-decoding are caught.

---

Duplicate comments:
In `@src/handlers/ib.rs`:
- Around line 29-37: The handler currently clones the Node with
node.to_owned_node(), which allocates per stanza; instead change
handle_ib_impl's signature to take a borrowed OwnedNodeRef (e.g.,
&wacore_binary::OwnedNodeRef) and call it with a reference to the existing
Arc-held node (for example handle_ib_impl(client, &*node).await or
handle_ib_impl(client, node.as_ref()).await) so no full Node is re-materialized;
update the handle_ib_impl function name/signature and all its call sites
accordingly (replace uses expecting owned Node with borrowed accessors).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69d87b85-6556-4c53-80cc-a61747216feb

📥 Commits

Reviewing files that changed from the base of the PR and between f457025 and 2b4beb4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (25)
  • Cargo.toml
  • src/client.rs
  • src/client/device_registry.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/unified_session.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/src/iq/privacy.rs

Comment thread src/client.rs Outdated
Comment thread src/client.rs Outdated
Comment thread src/handlers/basic.rs Outdated
Comment thread src/handlers/receipt.rs
Comment thread wacore/binary/src/jid.rs
Comment thread wacore/binary/src/jid.rs
@github-actions

github-actions Bot commented Apr 11, 2026

Copy link
Copy Markdown

Benchmark Results

3 improvement(s):

Benchmark Current Baseline Change
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 5,382 6,008 -10.4%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 15,731 17,414 -9.7%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 37,886 40,257 -5.9%
56 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,879 3,879 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 11,851 11,851 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,398 43,398 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,814 68,822 -0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,781 76,789 -0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,214 2,214 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,939 5,939 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 179,581 179,770 -0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 193,123 193,146 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 893,163 894,920 -0.2%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 995,125 997,331 -0.2%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,537,303 1,552,797 -1.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,779,139 2,790,271 -0.4%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 10,310,396 10,328,303 -0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 48,999,823 49,426,381 -0.9%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,564,153 12,693,442 -1.0%
binary_benchmark::marshal_group::bench_marshal_allocating 93,109 95,389 -2.4%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 93,142 95,422 -2.4%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 113,496 115,722 -1.9%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 102,873 105,315 -2.3%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 93,209 95,489 -2.4%
binary_benchmark::marshal_group::bench_marshal_long_string 15,416 15,662 -1.6%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,460 15,706 -1.6%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,558 17,798 -1.3%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 532,934 533,097 -0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,500 532,663 -0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 533,970 534,118 -0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,085,769 13,379,449 -2.2%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,030,095 13,379,193 -2.6%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,573,210 26,860,794 -1.1%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,621 2,716 -3.5%
binary_benchmark::unpack_group::bench_unpack_uncompressed 773 773 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,090 556,090 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,345 7,431 -1.2%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 87,464 89,832 -2.6%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,368 7,454 -1.2%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 87,495 89,863 -2.6%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,881 8,966 -0.9%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 102,993 105,408 -2.3%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 475,970 498,603 -4.5%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,383,997 17,375,559 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 162,069 162,069 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,512,660 5,512,660 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 163,085 163,085 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,493 298,493 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 713,231 713,231 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,695,444 12,583,400 +0.9%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,729,032 27,247,322 +1.8%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 124,352,143 125,393,753 -0.8%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,970 46,970 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,119,851 5,119,851 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 299,173 299,173 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +0.0%

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab548eabdc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

128 => crate::jid::Server::Hosted,
129 => crate::jid::Server::HostedLid,
n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
_ => crate::jid::Server::Pn,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject unsupported AD_JID domain types during decode

The new read_ad_jid mapping now coerces every unrecognized domain type to Server::Pn instead of returning an error. This silently reinterprets malformed or future domain bytes as PN identities, so stanzas can be routed through the wrong identity/session paths rather than being rejected as invalid input (the previous behavior).

Useful? React with 👍 / 👎.

Comment thread src/client.rs Outdated
Comment on lines +84 to +86
node.get_attr(k.as_str())
.and_then(|attr| attr.as_str())
.is_some_and(|s| s == v.as_str())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match JID attributes in node waiter filters

NodeFilter::matches now only compares attributes via attr.as_str(), but decoded JID attributes are represented as ValueRef::Jid (where as_str() is None). That means filters like NodeFilter::from_jid(...) (or any attr match against a JID-valued field) will never match incoming decoded nodes, so wait_for_node can time out even when the stanza arrived.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch 2 times, most recently from f0b4c50 to cedf071 Compare April 11, 2026 14:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (2)
src/client.rs (2)

4885-4892: 🧹 Nitpick | 🔵 Trivial

Mirror production unpacking in this helper.

Slicing bytes[1..] hardcodes the current framing byte and skips the same util::unpack path used by decrypt_frame, so these tests can drift from real behavior.

🧪 Suggested fix
 /// Helper to create an `Arc<OwnedNodeRef>` from a `Node` for tests.
 fn node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef> {
-    let bytes = wacore_binary::marshal::marshal(&node).expect("marshal should succeed");
-    // marshal() prepends a leading 0x00 format byte; OwnedNodeRef::new expects raw protocol bytes without it
+    let bytes = wacore_binary::marshal::marshal_auto(&node).expect("marshal should succeed");
+    let unpacked = wacore_binary::util::unpack(&bytes)
+        .expect("unpack should succeed")
+        .into_owned();
     Arc::new(
-        wacore_binary::OwnedNodeRef::new(bytes[1..].to_vec())
+        wacore_binary::OwnedNodeRef::new(unpacked)
             .expect("OwnedNodeRef::new should succeed"),
     )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4885 - 4892, The test helper node_to_owned_ref
currently slices off the first byte (bytes[1..]) which hardcodes the framing and
diverges from production unpacking; change it to call the same unpacking used in
decrypt_frame (e.g., util::unpack or the project’s unpack function) on the
marshalled bytes returned by wacore_binary::marshal::marshal and then pass the
unpacked/raw protocol bytes into wacore_binary::OwnedNodeRef::new so tests
mirror production framing behavior (keep the existing expect messages).

3182-3184: ⚠️ Potential issue | 🟠 Major

Avoid cloning every non-pair IQ before dispatch.

node.to_owned() runs before we know the stanza is pairing-related, so unrelated IQs still pay a full deep copy on the common path. Add a cheap pair-specific guard before materializing an owned Node, or move pair::handle_iq onto NodeRef/OwnedNodeRef.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3182 - 3184, The code currently clones every
incoming Node via node.to_owned() before calling pair::handle_iq, causing
unnecessary deep copies for non-pair IQs; fix this by either (A) adding a cheap,
non-allocating guard that inspects the NodeRef (e.g. check stanza type,
tag/child name or an attribute that identifies pairing IQs) and only call
node.to_owned() when that guard indicates a pairing IQ, or (B) update
pair::handle_iq to accept a NodeRef/OwnedNodeRef (e.g. &NodeRef) so you can call
pair::handle_iq(self, &node).await without cloning; locate the use of
node.to_owned() and pair::handle_iq in the client.rs snippet and apply one of
these changes to avoid the unnecessary deep copy.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 1655-1662: The IQ response path is forcing a deep copy via
node.to_owned_node() before sending to waiters; change the waiter payload type
and send path so we forward a zero-copy OwnedNodeRef (or Arc<OwnedNodeRef>)
instead of an OwnedNode, avoiding to_owned_node(). Update the response_waiters
map entry type and any waiter.send calls (the waiter receiver and its creation
sites) to accept OwnedNodeRef or Arc<OwnedNodeRef>, and when dispatching for IQ
(where nr.tag == "iq" and id is present) send the existing node reference (or
wrap it in Arc) rather than calling node.to_owned_node(); ensure constructors of
waiters use the new type so decode remains zero-copy through request/response
handling.
- Around line 81-87: The matches method currently only checks attr.as_str(),
which misses JID-backed attributes; update the attribute comparison inside
matches (the closure using node.get_attr(...).and_then(|attr| attr.as_str())...)
to accept JID-backed values too by extracting the attribute as either a plain
string or a JID and comparing their string forms (e.g., try attr.as_str() first,
then attr.as_jid().map(|j| j.to_string()) or equivalent) against the filter
value; keep the outer logic and use the same attribute key lookup on NodeRef so
NodeFilter::from_jid and sent-node waiters will match JID-backed attrs.

In `@src/handlers/chatstate.rs`:
- Around line 56-63: The current fix resolves the type mismatch by calling
node.to_owned_node() before ChatstateStanza::parse, but that allocates and loses
zero-copy benefits; change ChatstateStanza::parse to take a borrowed node
reference (e.g., &wacore_binary::NodeRef<'_> or
&wacore_binary::OwnedNodeRef::NodeRef view) so you can pass a reference directly
from the Arc<wacore_binary::OwnedNodeRef> without calling to_owned_node(),
update the parse signature and all callers (including handle) to accept the
borrowed reference, and remove the to_owned_node() allocation where possible to
preserve zero-copy semantics.

In `@src/handlers/notification.rs`:
- Around line 37-45: The handler currently calls node.to_owned_node(), which
deep-clones the stanza and defeats zero-copy; instead, stop rematerializing and
call handle_notification_impl with a reference to the existing node (e.g., pass
&*node or node.as_ref()) and change handle_notification_impl (and any helpers it
calls) to accept borrowed node types (prefer wacore_binary::NodeRef or
&wacore_binary::OwnedNodeRef) rather than an owned cloned node; update all call
sites of handle_notification_impl and its helpers to the new signature so they
operate on NodeRef/OwnedNodeRef references and preserve the zero-copy decode
path.

In `@src/message.rs`:
- Around line 3585-3586: The test code currently masks failures by using
unwrap_or(Server::Lid) after wacore_binary::jid::Server::try_from(lid_server);
replace each unwrap_or(...) with expect(...) on the Result returned by try_from
so a mapping failure is explicit (e.g., change
wacore_binary::jid::Server::try_from(lid_server).unwrap_or(wacore_binary::jid::Server::Lid)
to wacore_binary::jid::Server::try_from(lid_server).expect("unexpected Server
mapping for lid_server") ), and make the same replacement for the other
identical occurrences of try_from(...).unwrap_or(...) in this file so tests fail
visibly on bad mappings.

In `@wacore/benches/send_receive_benchmark.rs`:
- Around line 190-193: The benchmark fixture silently falls back on Server::Pn
due to unwrap_or_default; change the call in the Jid::new construction to fail
fast by replacing unwrap_or_default() with expect(...) so invalid server strings
cause an immediate panic; specifically update the
wacore_binary::jid::Server::try_from(server) usage inside the Jid::new call to
use expect with a clear message (e.g., "invalid server in benchmark fixture") so
Jid::new and Server::try_from failures surface during setup.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 4885-4892: The test helper node_to_owned_ref currently slices off
the first byte (bytes[1..]) which hardcodes the framing and diverges from
production unpacking; change it to call the same unpacking used in decrypt_frame
(e.g., util::unpack or the project’s unpack function) on the marshalled bytes
returned by wacore_binary::marshal::marshal and then pass the unpacked/raw
protocol bytes into wacore_binary::OwnedNodeRef::new so tests mirror production
framing behavior (keep the existing expect messages).
- Around line 3182-3184: The code currently clones every incoming Node via
node.to_owned() before calling pair::handle_iq, causing unnecessary deep copies
for non-pair IQs; fix this by either (A) adding a cheap, non-allocating guard
that inspects the NodeRef (e.g. check stanza type, tag/child name or an
attribute that identifies pairing IQs) and only call node.to_owned() when that
guard indicates a pairing IQ, or (B) update pair::handle_iq to accept a
NodeRef/OwnedNodeRef (e.g. &NodeRef) so you can call pair::handle_iq(self,
&node).await without cloning; locate the use of node.to_owned() and
pair::handle_iq in the client.rs snippet and apply one of these changes to avoid
the unnecessary deep copy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9ad351bd-03de-4a55-b330-43d597272468

📥 Commits

Reviewing files that changed from the base of the PR and between 2b4beb4 and ab548ea.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • Cargo.toml
  • src/client.rs
  • src/client/device_registry.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/unified_session.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/src/iq/privacy.rs

Comment thread src/client.rs
Comment thread src/client.rs Outdated
Comment thread src/handlers/chatstate.rs Outdated
Comment thread src/handlers/notification.rs
Comment thread src/message.rs Outdated
Comment thread wacore/benches/send_receive_benchmark.rs
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch 2 times, most recently from d2fcf22 to 951c6d5 Compare April 11, 2026 14:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (8)
src/message.rs (1)

3585-3586: ⚠️ Potential issue | 🟡 Minor

Don't silently recover from a broken server mapping in these tests.

unwrap_or(Server::Lid) keeps these branches green even if lid_server stops mapping correctly, which weakens the regression signal for the enum migration. Use expect(...) so a bad mapping fails loudly.

Minimal fix
-                    server: wacore_binary::jid::Server::try_from(lid_server)
-                        .unwrap_or(wacore_binary::jid::Server::Lid),
+                    server: wacore_binary::jid::Server::try_from(lid_server)
+                        .expect("lid_server should map to Server::Lid"),

Also applies to: 3595-3596, 3704-3705, 3714-3715, 3809-3810, 3818-3819

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 3585 - 3586, Replace the silent fallback from
unwrap_or(Server::Lid) with expect so tests fail loudly when the mapping is
broken: locate each use of
wacore_binary::jid::Server::try_from(lid_server).unwrap_or(wacore_binary::jid::Server::Lid)
(e.g., in src/message.rs at the instances around the try_from calls you've
changed) and change to call expect(...) on the Result, providing a clear message
like "unexpected Server mapping for lid_server: {lid_server}" so a bad mapping
panics instead of silently using Server::Lid; apply the same replacement to the
other occurrences noted (around the other try_from sites).
src/handlers/notification.rs (1)

37-44: 🛠️ Refactor suggestion | 🟠 Major

This handler still breaks the zero-copy path.

node.to_owned_node() rematerializes the full stanza before any parsing, so notifications lose the allocation savings introduced by OwnedNodeRef. Push handle_notification_impl and its helpers onto OwnedNodeRef/NodeRef instead of cloning at the boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 37 - 44, The handler currently
calls node.to_owned_node(), which defeats zero-copy by rematerializing the
stanza; instead, change async fn handle to pass a reference to the existing
OwnedNodeRef/NodeRef (e.g., &Arc<wacore_binary::OwnedNodeRef> or
&wacore_binary::NodeRef) into the processing path and move
handle_notification_impl and its helper functions as methods or free functions
that accept &OwnedNodeRef / &NodeRef (or implement them as impl blocks on
OwnedNodeRef/NodeRef). Replace the to_owned_node() call in handle with a direct
call into the new OwnedNodeRef/NodeRef-based method (e.g.,
node.handle_notification_impl(...)) and update all helper usages to operate on
the reference types so no cloning/rematerialization occurs at the boundary.
src/handlers/chatstate.rs (1)

56-63: 🧹 Nitpick | 🔵 Trivial

ChatstateStanza::parse is still forcing an owned clone.

This handler immediately materializes the stanza before parsing, so the new OwnedNodeRef API does not reduce allocations on this path yet. If you switch the parser to &NodeRef<'_>, chatstate handling stays zero-copy end to end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/chatstate.rs` around lines 56 - 63, The handler calls
node.to_owned_node() and then ChatstateStanza::parse(&owned), forcing an owned
clone; update the parser to accept a borrowed node reference (e.g., change
ChatstateStanza::parse signature to take &NodeRef<'_>) and call it with the
existing node reference instead of creating an OwnedNodeRef (remove the
to_owned_node() usage in the handle method). Ensure ChatstateStanza::parse and
any downstream uses are updated to work with &NodeRef<'_> so chatstate handling
remains zero-copy end-to-end.
src/handlers/receipt.rs (1)

22-29: 🛠️ Refactor suggestion | 🟠 Major

Stop rebuilding a Node on every receipt.

Arc::new(node.to_owned_node()) brings back a full tree copy plus a new Arc allocation on one of the busiest stanza paths. To preserve the zero-copy win here, move Client::handle_receipt to &NodeRef<'_> or Arc<OwnedNodeRef> and pass the borrowed/ref-counted node through directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/receipt.rs` around lines 22 - 29, The handler is cloning the
entire node tree with node.to_owned_node() on every receipt; change
Client::handle_receipt to accept a ref-counted node
(Arc<wacore_binary::OwnedNodeRef>) or a borrowed NodeRef (e.g.,
&wacore_binary::NodeRef) so you can pass the node directly without cloning;
update the Client::handle_receipt signature and all its callers/implementations
accordingly, and in receipt.rs call client.handle_receipt(node.clone()).await
(or pass &*node if you choose &NodeRef) instead of
Arc::new(node.to_owned_node()) to preserve zero-copy and avoid the tree copy.
src/handlers/basic.rs (1)

84-93: 🧹 Nitpick | 🔵 Trivial

<ack> handler still allocates via to_owned_node().

This was flagged in a previous review: calling to_owned_node() clones the entire node, negating the zero-copy benefit for ack handling. The suggested fix was to change Client::handle_ack_response to accept &wacore_binary::NodeRef<'_> and pass node.get() directly.

Consider refactoring handle_ack_response to accept a borrowed reference:

♻️ Suggested direction
-        let owned_node = node.to_owned_node();
-        client.handle_ack_response(owned_node).await;
+        client.handle_ack_response(node.get()).await;

And update handle_ack_response signature in src/client.rs:

pub(crate) async fn handle_ack_response(
    self: &Arc<Self>,
    node: &wacore_binary::NodeRef<'_>,
) {
    // existing ack parsing logic adapted for NodeRef
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/basic.rs` around lines 84 - 93, The handler currently clones the
node via to_owned_node() and passes an OwnedNodeRef into
Client::handle_ack_response, losing zero-copy benefits; change the handler in
handle(...) to call client.handle_ack_response with a borrowed NodeRef by
passing node.get() (i.e., a &wacore_binary::NodeRef<'_>) instead of owned_node,
and update Client::handle_ack_response signature (and its callers) to accept
self: &Arc<Self> and node: &wacore_binary::NodeRef<'_> so the ack parsing logic
works with a borrowed NodeRef without cloning.
src/client.rs (3)

4886-4893: ⚠️ Potential issue | 🟡 Minor

Make the test helper use the same unpack contract as production.

Hardcoding bytes[1..] bakes in the current framing assumption, while decrypt_frame goes through wacore_binary::util::unpack. If framing or compression changes again, these tests can drift from the real receive path.

🧪 Proposed fix
 fn node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef> {
-    let bytes = wacore_binary::marshal::marshal(&node).expect("marshal should succeed");
-    // marshal() prepends a leading 0x00 format byte; OwnedNodeRef::new expects raw protocol bytes without it
+    let bytes = wacore_binary::marshal::marshal_auto(&node).expect("marshal should succeed");
+    let unpacked = wacore_binary::util::unpack(&bytes)
+        .expect("unpack should succeed")
+        .into_owned();
     Arc::new(
-        wacore_binary::OwnedNodeRef::new(bytes[1..].to_vec())
+        wacore_binary::OwnedNodeRef::new(unpacked)
             .expect("OwnedNodeRef::new should succeed"),
     )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4886 - 4893, The test helper node_to_owned_ref
currently strips the leading format byte by slicing bytes[1..] which diverges
from production; instead call wacore_binary::util::unpack on the marshaled bytes
(the same contract decrypt_frame uses) and pass the unpacked payload into
wacore_binary::OwnedNodeRef::new, propagating or expect()-ing the Result as
appropriate so tests exercise the same framing/unpacking logic as production.

1656-1663: ⚠️ Potential issue | 🟠 Major

Keep IQ waiter delivery zero-copy.

node.to_owned_node() rematerializes the full stanza on every matched IQ response, which puts allocation/copy back onto a very common path (connect, props, app-state sync, etc.). This optimization currently stops at the waiter boundary; carry Arc<OwnedNodeRef> through response_waiters and only materialize an owned Node at the final consumer that truly needs it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1656 - 1663, The code currently calls
node.to_owned_node() when delivering IQ responses which forces allocation;
change the response_waiters storage and send path to carry Arc<OwnedNodeRef>
instead of Node so delivery is zero-copy: update the type stored in
self.response_waiters to Arc<OwnedNodeRef>, stop calling node.to_owned_node()
here, send an Arc clone (e.g. waiter.send(arc_node.clone())), and defer calling
to_owned_node() only in the final consumer that actually needs an owned Node;
adjust any related type signatures (response_waiters map value, waiter channel
type, and consumer handlers) to accept Arc<OwnedNodeRef>.

3183-3185: ⚠️ Potential issue | 🟠 Major

Don’t deep-clone every non-ping IQ before pair dispatch.

The unconditional node.to_owned() reintroduces a full copy on the hot IQ path even when the stanza is unrelated to pairing. Either make pair::handle_iq consume a ref-based node, or gate the materialization behind a cheap pair-specific pre-check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3183 - 3185, The code clones every incoming IQ
via node.to_owned() before dispatching to pair::handle_iq, causing unnecessary
copies; either change pair::handle_iq signature to accept a borrow (e.g., &Node
or &Stanza) so you can pass &node directly, or add a cheap pre-check (inspect
node.tag()/attrs/type/ping marker) and only call node.to_owned() when the stanza
is actually pair-related; update all call sites and types in pair::handle_iq
(and its helpers) accordingly to avoid the unconditional deep clone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 3624-3637: build_ack_node currently calls to_string_cow() when
constructing NodeValue for id, from, participant and type which forces
JID-backed attributes through stringification and extra allocations; instead,
preserve the typed/borrowed attribute values when creating NodeValue (avoid
to_string_cow()) by using the attribute value directly from node.get_attr(...)
or the NodeRef API that returns a borrowed/typed representation so NodeValue is
built without intermediate String allocation (update the constructions for id,
from, participant and the type branch in build_ack_node to use the
direct/borrowed attr values).

In `@src/handlers/ib.rs`:
- Around line 29-36: The code currently calls node.to_owned_node() in handle
which unnecessarily allocates the full tree; change handle to pass a borrowed
reference instead by using node.get() or &NodeRef<'_> and update handle_ib_impl
signature to accept a borrowed NodeRef (or &NodeRef) so it reads from the
borrowed data without allocation; ensure call sites using handle_ib_impl (and
its async signature) are adjusted to accept the borrowed type and that lifetimes
compile.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 4886-4893: The test helper node_to_owned_ref currently strips the
leading format byte by slicing bytes[1..] which diverges from production;
instead call wacore_binary::util::unpack on the marshaled bytes (the same
contract decrypt_frame uses) and pass the unpacked payload into
wacore_binary::OwnedNodeRef::new, propagating or expect()-ing the Result as
appropriate so tests exercise the same framing/unpacking logic as production.
- Around line 1656-1663: The code currently calls node.to_owned_node() when
delivering IQ responses which forces allocation; change the response_waiters
storage and send path to carry Arc<OwnedNodeRef> instead of Node so delivery is
zero-copy: update the type stored in self.response_waiters to Arc<OwnedNodeRef>,
stop calling node.to_owned_node() here, send an Arc clone (e.g.
waiter.send(arc_node.clone())), and defer calling to_owned_node() only in the
final consumer that actually needs an owned Node; adjust any related type
signatures (response_waiters map value, waiter channel type, and consumer
handlers) to accept Arc<OwnedNodeRef>.
- Around line 3183-3185: The code clones every incoming IQ via node.to_owned()
before dispatching to pair::handle_iq, causing unnecessary copies; either change
pair::handle_iq signature to accept a borrow (e.g., &Node or &Stanza) so you can
pass &node directly, or add a cheap pre-check (inspect
node.tag()/attrs/type/ping marker) and only call node.to_owned() when the stanza
is actually pair-related; update all call sites and types in pair::handle_iq
(and its helpers) accordingly to avoid the unconditional deep clone.

In `@src/handlers/basic.rs`:
- Around line 84-93: The handler currently clones the node via to_owned_node()
and passes an OwnedNodeRef into Client::handle_ack_response, losing zero-copy
benefits; change the handler in handle(...) to call client.handle_ack_response
with a borrowed NodeRef by passing node.get() (i.e., a
&wacore_binary::NodeRef<'_>) instead of owned_node, and update
Client::handle_ack_response signature (and its callers) to accept self:
&Arc<Self> and node: &wacore_binary::NodeRef<'_> so the ack parsing logic works
with a borrowed NodeRef without cloning.

In `@src/handlers/chatstate.rs`:
- Around line 56-63: The handler calls node.to_owned_node() and then
ChatstateStanza::parse(&owned), forcing an owned clone; update the parser to
accept a borrowed node reference (e.g., change ChatstateStanza::parse signature
to take &NodeRef<'_>) and call it with the existing node reference instead of
creating an OwnedNodeRef (remove the to_owned_node() usage in the handle
method). Ensure ChatstateStanza::parse and any downstream uses are updated to
work with &NodeRef<'_> so chatstate handling remains zero-copy end-to-end.

In `@src/handlers/notification.rs`:
- Around line 37-44: The handler currently calls node.to_owned_node(), which
defeats zero-copy by rematerializing the stanza; instead, change async fn handle
to pass a reference to the existing OwnedNodeRef/NodeRef (e.g.,
&Arc<wacore_binary::OwnedNodeRef> or &wacore_binary::NodeRef) into the
processing path and move handle_notification_impl and its helper functions as
methods or free functions that accept &OwnedNodeRef / &NodeRef (or implement
them as impl blocks on OwnedNodeRef/NodeRef). Replace the to_owned_node() call
in handle with a direct call into the new OwnedNodeRef/NodeRef-based method
(e.g., node.handle_notification_impl(...)) and update all helper usages to
operate on the reference types so no cloning/rematerialization occurs at the
boundary.

In `@src/handlers/receipt.rs`:
- Around line 22-29: The handler is cloning the entire node tree with
node.to_owned_node() on every receipt; change Client::handle_receipt to accept a
ref-counted node (Arc<wacore_binary::OwnedNodeRef>) or a borrowed NodeRef (e.g.,
&wacore_binary::NodeRef) so you can pass the node directly without cloning;
update the Client::handle_receipt signature and all its callers/implementations
accordingly, and in receipt.rs call client.handle_receipt(node.clone()).await
(or pass &*node if you choose &NodeRef) instead of
Arc::new(node.to_owned_node()) to preserve zero-copy and avoid the tree copy.

In `@src/message.rs`:
- Around line 3585-3586: Replace the silent fallback from unwrap_or(Server::Lid)
with expect so tests fail loudly when the mapping is broken: locate each use of
wacore_binary::jid::Server::try_from(lid_server).unwrap_or(wacore_binary::jid::Server::Lid)
(e.g., in src/message.rs at the instances around the try_from calls you've
changed) and change to call expect(...) on the Result, providing a clear message
like "unexpected Server mapping for lid_server: {lid_server}" so a bad mapping
panics instead of silently using Server::Lid; apply the same replacement to the
other occurrences noted (around the other try_from sites).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd9a7152-8a87-4043-95da-c5f24d49bfff

📥 Commits

Reviewing files that changed from the base of the PR and between ab548ea and 951c6d5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (27)
  • Cargo.toml
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/unified_session.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/src/iq/privacy.rs

Comment thread src/client.rs Outdated
Comment thread src/handlers/ib.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 951c6d53c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/jid.rs Outdated
Comment on lines +166 to +168
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum Server {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve wire-format server strings in serde output

Deriving Serialize/Deserialize directly on Server changes JID JSON shape from protocol domains (for example "s.whatsapp.net") to enum variant names (for example "Pn"). Because wacore enables wacore-binary's serde feature, this affects all serialized Jid fields in public events/state and breaks backward compatibility when reading previously stored JSON that still contains domain strings. Add explicit serde renames (or custom serde impls) so Server encodes/decodes using as_str() values.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from 951c6d5 to d4f7e86 Compare April 11, 2026 15:26

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4f7e86277

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.rs Outdated
Comment on lines +3630 to +3633
let from = NodeValue::from(node.get_attr("from")?.to_string_cow().as_ref());
let participant = node
.get_attr("participant")
.map(|v| NodeValue::from(v.to_string_cow().as_ref()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve JID structure in ACK destination attrs

Avoid converting from/participant to plain strings when building ACKs, because this drops structured JID fields that are not representable in Display output (notably integrator for INTEROP_JID). In this path, an incoming interop stanza can be ACKed with to rewritten as a plain JID_PAIR (or otherwise normalized) instead of the original structured JID, which can cause ACK routing/matching failures for interop traffic.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/client/lid_pn.rs (1)

139-150: ⚠️ Potential issue | 🟡 Minor

Use typed Server enum variants instead of string constants for consistency.

Lines 142 and 145 compare target.server against string constants (DEFAULT_USER_SERVER, HIDDEN_USER_SERVER), while line 150 constructs with the typed Server::Lid enum. Although Server implements PartialEq<str> and the code works, mixing string and typed comparisons reduces consistency. Replace the string-based branching with typed Server::Pn and Server::Lid variants.

Suggested change
-        let pn_server = wacore_binary::jid::DEFAULT_USER_SERVER;
-        let lid_server = wacore_binary::jid::HIDDEN_USER_SERVER;
-
-        if target.server == lid_server {
+        if target.server == Server::Lid {
             // Already a LID - use it directly
             target.clone()
-        } else if target.server == pn_server {
+        } else if target.server == Server::Pn {
             // PN JID - check if we have a LID mapping
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/lid_pn.rs` around lines 139 - 150, The code compares target.server
to string constants DEFAULT_USER_SERVER and HIDDEN_USER_SERVER; instead, use the
typed Server enum variants for consistency—replace checks against
wacore_binary::jid::DEFAULT_USER_SERVER and
wacore_binary::jid::HIDDEN_USER_SERVER with comparisons to
wacore_binary::jid::Server::Pn and wacore_binary::jid::Server::Lid respectively
(keeping the existing Jid construction that uses Server::Lid), and adjust any
imports or fully-qualify Server where needed so all server equality checks
consistently use the Server enum.
src/handlers/ib.rs (1)

128-133: ⚠️ Potential issue | 🟠 Major

Avoid direct Device mutation in the edge-routing write path.

This code writes device state via modify_device(...) directly. Route this through DeviceCommand + PersistenceManager::process_command() to keep state transitions centralized and consistent.

As per coding guidelines: Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 128 - 133, The direct mutation using
client_clone.persistence_manager.modify_device(...) to set
device.edge_routing_info = Some(routing_bytes) must be replaced by constructing
an appropriate DeviceCommand (e.g., SetEdgeRouting or similar) carrying
routing_bytes and sending it to
client_clone.persistence_manager.process_command(command). Also update reads to
use client_clone.persistence_manager.get_device_snapshot() where needed; ensure
you remove the direct modify_device call and use the
PersistenceManager::process_command() path so state changes go through
centralized command handling.
src/message.rs (2)

73-90: ⚠️ Potential issue | 🟡 Minor

Warn before dropping malformed newsletter plaintext.

If <plaintext> exists but its content is not bytes, this path now returns silently and the stanza disappears without any diagnostic. Please log that case before returning.

💡 Minimal fix
-        if let Some(NodeContentRef::Bytes(bytes)) = plaintext_node.content.as_deref() {
-            match wa::Message::decode(bytes.as_ref()) {
-                Ok(msg) => {
-                    log::info!(
-                        "[msg:{}] Received newsletter plaintext message from {}",
-                        info.id,
-                        info.source.chat
-                    );
-                    self.dispatch_parsed_message(msg, info);
-                }
-                Err(e) => {
-                    log::warn!(
-                        "[msg:{}] Failed to decode newsletter plaintext: {e}",
-                        info.id
-                    );
-                }
-            }
-        }
+        let Some(NodeContentRef::Bytes(bytes)) = plaintext_node.content.as_deref() else {
+            log::warn!(
+                "[msg:{}] Newsletter <plaintext> child has non-byte content",
+                info.id
+            );
+            return;
+        };
+
+        match wa::Message::decode(bytes.as_ref()) {
+            Ok(msg) => {
+                log::info!(
+                    "[msg:{}] Received newsletter plaintext message from {}",
+                    info.id,
+                    info.source.chat
+                );
+                self.dispatch_parsed_message(msg, info);
+            }
+            Err(e) => {
+                log::warn!(
+                    "[msg:{}] Failed to decode newsletter plaintext: {e}",
+                    info.id
+                );
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 73 - 90, The code silently returns when
plaintext_node.content exists but isn't NodeContentRef::Bytes; update the
handling in the plaintext block surrounding plaintext_node and
wa::Message::decode so that if plaintext_node.content.as_deref() is Some but not
Bytes you emit a warning (e.g., using log::warn!) that includes info.id and a
brief description that plaintext content was present but not bytes before
returning, while keeping the existing decode/dispatch_parsed_message flow for
the Bytes case.

366-372: 🧹 Nitpick | 🔵 Trivial

Avoid re-acquiring custom_enc_handlers for every <enc> node.

This read().await sits inside the hot per-node decode loop. Hoisting the read guard to the loop scope removes repeated async lock acquisitions for multi-enc stanzas; just keep that scope tight so the guard is dropped before the next .await after the loop.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 366 - 372, The code repeatedly calls
self.custom_enc_handlers.read().await inside the per-<enc> node decode loop
which reacquires the async RwLock on every node; hoist the read guard outside
the inner node loop by calling let guard = self.custom_enc_handlers.read().await
once (or at the start of the multi-enc stanza), use
guard.get(enc_type.as_ref()).cloned() to fetch the handler, then drop the guard
(or let it go out of scope) before any subsequent await in the loop so the async
lock isn't held across awaits and isn't re-acquired per node; refer to
custom_enc_handlers, enc_type, and the handler variable when making this change.
♻️ Duplicate comments (6)
src/handlers/notification.rs (1)

206-211: 🧹 Nitpick | 🔵 Trivial

to_owned_node() conversions remain for unmigrated APIs.

Several notification handlers still call to_owned_node() before passing to parsing functions (e.g., handle_pair_code_notification, DeviceNotification::try_parse, BusinessNotification::try_parse, GroupNotification::try_from_node). This defeats zero-copy for these paths but is necessary until downstream APIs are migrated to accept &NodeRef<'_>.

Consider tracking migration of these parsing APIs as follow-up work to fully realize the zero-copy benefits.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 206 - 211, Several handlers
currently call to_owned_node() which forces allocations and prevents zero-copy;
update the parsing/handler APIs to accept a NodeRef<'_> (borrowed) and then pass
&node (the existing NodeRef) instead of calling to_owned_node() in call sites
such as crate::pair_code::handle_pair_code_notification,
DeviceNotification::try_parse, BusinessNotification::try_parse, and
GroupNotification::try_from_node; change those function signatures to take
&NodeRef<'_> (or &impl AsNodeRef) and update their internals to work with the
borrowed node so you can remove the to_owned_node() conversions across these
notification handlers, tracking any remaining unported call sites as follow-up
work.
src/message.rs (1)

3598-3599: ⚠️ Potential issue | 🟡 Minor

Fail these server-mapping tests loudly instead of defaulting to Server::Lid.

These unwrap_or(...) fallbacks still hide a broken lid_serverServer mapping and keep the migration tests green. In test code, prefer expect(...) so the regression is visible.

🔎 Minimal fix
-                    server: wacore_binary::jid::Server::try_from(lid_server)
-                        .unwrap_or(wacore_binary::jid::Server::Lid),
+                    server: wacore_binary::jid::Server::try_from(lid_server)
+                        .expect("lid_server should map to a typed Server"),

Apply the same replacement to the other identical copies in this file.

Also applies to: 3608-3609, 3717-3718, 3727-3728, 3822-3823, 3831-3832

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 3598 - 3599, The test code currently masks
failures by using unwrap_or(wacore_binary::jid::Server::Lid) after
wacore_binary::jid::Server::try_from(lid_server); replace those fallbacks with
expect(...) so the conversion failure fails loudly and surfaces regressions —
update the expression where Server::try_from(lid_server) is called (and the
other identical occurrences in this file) to call expect with a clear message
indicating the failed lid_server → Server mapping instead of defaulting to
Server::Lid.
src/client.rs (4)

1660-1667: ⚠️ Potential issue | 🟠 Major

Keep awaited IQ responses zero-copy.

Line 1666 rematerializes the full stanza into Node, so connect/app-state sync still pays a deep copy on every waiter hit. Carry OwnedNodeRef/Arc<OwnedNodeRef> through response_waiters instead of converting back here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1660 - 1667, The current code rematerializes the
full stanza with node.to_owned_node() before sending to the waiter, causing a
deep copy on every waiter hit; change the response_waiters map to hold
OwnedNodeRef (or Arc<OwnedNodeRef>) instead of OwnedNode and update all
insertion sites to store an OwnedNodeRef/Arc there so that in this block you
simply clone/clone the Arc and send that (avoid calling node.to_owned_node());
adjust uses of response_waiters.lock().await.remove(id) and waiter.send(...) to
accept/send the OwnedNodeRef/Arc type and update any types/traits (e.g., sender
type) accordingly.

3188-3190: ⚠️ Potential issue | 🟠 Major

Avoid cloning every non-ping IQ before pair dispatch.

Line 3189 unconditionally calls node.to_owned(), which puts a deep copy back on the general IQ path even when the stanza has nothing to do with pairing. Either make pair::handle_iq consume NodeRef/OwnedNodeRef, or add a cheap pair-specific pre-check before materializing an owned Node.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3188 - 3190, The code currently always clones the
stanza via node.to_owned() before calling pair::handle_iq, causing unnecessary
copies; either change pair::handle_iq to accept a non-owning reference type
(e.g., NodeRef or OwnedNodeRef) so it can operate without forcing a deep clone,
or add a cheap pre-check (inspect node.tag/name/attrs on the NodeRef) right
before the call and only call node.to_owned() when the stanza matches the
pairing criteria; update the pair::handle_iq signature and call sites (or add
the predicate check) accordingly so non-pair IQs avoid the allocation.

3628-3641: 🧹 Nitpick | 🔵 Trivial

Preserve typed attrs in build_ack_node.

Lines 3629-3641 still stringify JID-backed attrs before rebuilding NodeValue, so every ackable stanza pays extra formatting/allocation work in the ACK hot path. If wacore_binary already exposes a direct owned/value-preserving conversion, use that instead of the to_string_cow() round-trip.

Run this read-only search to confirm whether a direct ValueRefNodeValue path already exists:

#!/bin/bash
set -euo pipefail
rg -n -C3 --type rust 'enum ValueRef|struct ValueRef|to_string_cow|impl .*From<.*ValueRef.*> for NodeValue|NodeValue::Jid' .

Expected: locate a direct owned/value-preserving conversion helper in wacore_binary; if one exists, build_ack_node can stop round-tripping these attrs through strings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3628 - 3641, The build_ack_node function
currently forces JID-backed attributes (id, from, participant, and the type attr
path) through to_string_cow() then reconstructs NodeValue, causing extra
formatting/allocation on the ACK hot path; replace those round-trips by using
the value-preserving conversion from wacore_binary's ValueRef (or the crate's
direct impl From<ValueRef> for NodeValue / NodeValue::Jid helper) so you
construct NodeValue directly from the attribute ValueRef (e.g., change map(|v|
NodeValue::from(v.to_string_cow().as_ref())) to the direct conversion like
NodeValue::from(v) or the crate-specific ValueRef→NodeValue API) for id, from,
participant and the type branch, preserving typed values and avoiding string
allocations.

4890-4896: 🧹 Nitpick | 🔵 Trivial

Make the test helper follow the production unpack contract.

Lines 4892-4895 hardcode the current wire framing via bytes[1..]. Using marshal_auto() plus wacore_binary::util::unpack() keeps this helper aligned if framing or compression behavior changes.

Proposed fix
 fn node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef> {
-    let bytes = wacore_binary::marshal::marshal(&node).expect("marshal should succeed");
-    // marshal() prepends a leading 0x00 format byte; OwnedNodeRef::new expects raw protocol bytes without it
+    let bytes = wacore_binary::marshal::marshal_auto(&node).expect("marshal should succeed");
+    let unpacked = wacore_binary::util::unpack(&bytes)
+        .expect("unpack should succeed")
+        .into_owned();
     Arc::new(
-        wacore_binary::OwnedNodeRef::new(bytes[1..].to_vec())
+        wacore_binary::OwnedNodeRef::new(unpacked)
             .expect("OwnedNodeRef::new should succeed"),
     )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4890 - 4896, The test helper node_to_owned_ref
currently hardcodes framing by calling wacore_binary::marshal and slicing
bytes[1..]; change it to use wacore_binary::marshal_auto(&node) and then pass
the result through wacore_binary::util::unpack(...) to obtain the raw protocol
payload, and then construct
Arc::new(wacore_binary::OwnedNodeRef::new(unpacked).expect(...)). This ensures
node_to_owned_ref follows the production unpack contract and will stay correct
if framing/compression changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 240-241: The cleanup currently cross-products user aliases by
calling all_keys() and iterating both Server::Lid and Server::Pn, which throws
away the LID/PN distinction from resolve_lookup_keys() and can purge unrelated
accounts; update delete_sessions_for_devices() (and the similar block at
303-310) to match on resolve_lookup_keys() (UserLookupKeys::LidWithPn,
::PnWithLid, ::Unknown) and only call clear_device_record()/purge for the
specific (user, server) pairs returned (use the caller's server for the Unknown
case) rather than iterating both server variants or using all_keys(). Ensure
clear_device_record() is invoked with the exact (user, server) tuple from the
match so you preserve the original alias semantics.

In `@src/features/media_reupload.rs`:
- Around line 110-112: The code currently calls
notification_node.to_owned_node() which deep-clones the node and payload;
instead keep the borrowed path by adding an overload that accepts a borrowed
node reference (e.g., parse_media_retry_notification_borrowed or change
parse_media_retry_notification to accept &Node/NodeRef) and call that here with
the existing notification_node (avoid to_owned_node()); update the
parse_media_retry_notification implementation (or add a small wrapper) to
operate only on the borrowed stanza without requiring ownership so no deep clone
occurs.

In `@src/handlers/ib.rs`:
- Around line 113-116: The nested pattern matching around extracting
routing_info can be simplified with a let-chain: replace the two-level `if let`
(the call to `child.get_optional_child("routing_info")` binding
`routing_info_node` and the inner `if let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref()`) with a single `if let
Some(routing_info_node) = child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... }` so that routing_info_node and
routing_bytes are bound in one condition (update the block body to use these
bindings).

In `@src/handlers/receipt.rs`:
- Line 28: The call currently does Arc::clone(&node) before
client.handle_receipt which unnecessarily bumps the Arc refcount; instead move
node into the call (client.handle_receipt(node).await) and remove the Arc::clone
usage. Update the handle_receipt signature if needed so it accepts the owned
Arc<Node> (by value) rather than borrowing, and ensure there are no subsequent
uses of node after this call so the move is valid.

In `@src/retry.rs`:
- Around line 57-64: The retry path currently materializes an owned Node inside
handle_retry_receipt (via node.to_owned_node()), which forces copying of the
retry stanza; change handle_retry_receipt to accept and work with a borrowed
node reference (e.g. &NodeRef<'_> or &OwnedNodeRef) and parse directly from
node.get() instead of calling to_owned_node(), and update/move any helper
functions that currently expect owned nodes to accept the borrowed NodeRef so no
allocation occurs when handling Receipt in handle_retry_receipt.

In `@wacore/binary/src/decoder.rs`:
- Around line 109-115: Add a regression test that exercises the new fail-fast
path in read_jid_pair: craft a minimal binary buffer representing a JID_PAIR
where the server string is invalid, feed it to the decoder (the same decoding
entry used in tests that ultimately calls read_jid_pair) and assert the decode
returns Err(BinaryError::AttrParse(..)); this ensures Server::try_from(...) in
read_jid_pair fails and the AttrParse error is produced rather than falling back
to the old Server::Pn behavior. Include references to read_jid_pair,
Server::try_from, and BinaryError::AttrParse in the test assertion so it fails
if the old fallback returns a valid Server instead of an error.

In `@wacore/src/iq/chatstate.rs`:
- Around line 140-145: The parse() path using attrs.optional_jid("from") treats
both missing and malformed JIDs the same because optional_jid() records parse
failures in attrs.errors and returns None, making
ChatstateParseError::InvalidJid unreachable; fix by distinguishing malformed vs
missing JID: after calling let mut attrs = node.attr_parser(); use either a
required-parsing helper (e.g., call attrs.jid("from") or similar) that returns a
parse error you can map to ChatstateParseError::InvalidJid, or, if you keep
optional_jid("from"), immediately inspect attrs.errors before returning
MissingFrom and return ChatstateParseError::InvalidJid when a JID parse error
for "from" is present (ensuring SelfEcho logic still checks
attrs.optional_jid("to") as before).

---

Outside diff comments:
In `@src/client/lid_pn.rs`:
- Around line 139-150: The code compares target.server to string constants
DEFAULT_USER_SERVER and HIDDEN_USER_SERVER; instead, use the typed Server enum
variants for consistency—replace checks against
wacore_binary::jid::DEFAULT_USER_SERVER and
wacore_binary::jid::HIDDEN_USER_SERVER with comparisons to
wacore_binary::jid::Server::Pn and wacore_binary::jid::Server::Lid respectively
(keeping the existing Jid construction that uses Server::Lid), and adjust any
imports or fully-qualify Server where needed so all server equality checks
consistently use the Server enum.

In `@src/handlers/ib.rs`:
- Around line 128-133: The direct mutation using
client_clone.persistence_manager.modify_device(...) to set
device.edge_routing_info = Some(routing_bytes) must be replaced by constructing
an appropriate DeviceCommand (e.g., SetEdgeRouting or similar) carrying
routing_bytes and sending it to
client_clone.persistence_manager.process_command(command). Also update reads to
use client_clone.persistence_manager.get_device_snapshot() where needed; ensure
you remove the direct modify_device call and use the
PersistenceManager::process_command() path so state changes go through
centralized command handling.

In `@src/message.rs`:
- Around line 73-90: The code silently returns when plaintext_node.content
exists but isn't NodeContentRef::Bytes; update the handling in the plaintext
block surrounding plaintext_node and wa::Message::decode so that if
plaintext_node.content.as_deref() is Some but not Bytes you emit a warning
(e.g., using log::warn!) that includes info.id and a brief description that
plaintext content was present but not bytes before returning, while keeping the
existing decode/dispatch_parsed_message flow for the Bytes case.
- Around line 366-372: The code repeatedly calls
self.custom_enc_handlers.read().await inside the per-<enc> node decode loop
which reacquires the async RwLock on every node; hoist the read guard outside
the inner node loop by calling let guard = self.custom_enc_handlers.read().await
once (or at the start of the multi-enc stanza), use
guard.get(enc_type.as_ref()).cloned() to fetch the handler, then drop the guard
(or let it go out of scope) before any subsequent await in the loop so the async
lock isn't held across awaits and isn't re-acquired per node; refer to
custom_enc_handlers, enc_type, and the handler variable when making this change.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 1660-1667: The current code rematerializes the full stanza with
node.to_owned_node() before sending to the waiter, causing a deep copy on every
waiter hit; change the response_waiters map to hold OwnedNodeRef (or
Arc<OwnedNodeRef>) instead of OwnedNode and update all insertion sites to store
an OwnedNodeRef/Arc there so that in this block you simply clone/clone the Arc
and send that (avoid calling node.to_owned_node()); adjust uses of
response_waiters.lock().await.remove(id) and waiter.send(...) to accept/send the
OwnedNodeRef/Arc type and update any types/traits (e.g., sender type)
accordingly.
- Around line 3188-3190: The code currently always clones the stanza via
node.to_owned() before calling pair::handle_iq, causing unnecessary copies;
either change pair::handle_iq to accept a non-owning reference type (e.g.,
NodeRef or OwnedNodeRef) so it can operate without forcing a deep clone, or add
a cheap pre-check (inspect node.tag/name/attrs on the NodeRef) right before the
call and only call node.to_owned() when the stanza matches the pairing criteria;
update the pair::handle_iq signature and call sites (or add the predicate check)
accordingly so non-pair IQs avoid the allocation.
- Around line 3628-3641: The build_ack_node function currently forces JID-backed
attributes (id, from, participant, and the type attr path) through
to_string_cow() then reconstructs NodeValue, causing extra formatting/allocation
on the ACK hot path; replace those round-trips by using the value-preserving
conversion from wacore_binary's ValueRef (or the crate's direct impl
From<ValueRef> for NodeValue / NodeValue::Jid helper) so you construct NodeValue
directly from the attribute ValueRef (e.g., change map(|v|
NodeValue::from(v.to_string_cow().as_ref())) to the direct conversion like
NodeValue::from(v) or the crate-specific ValueRef→NodeValue API) for id, from,
participant and the type branch, preserving typed values and avoiding string
allocations.
- Around line 4890-4896: The test helper node_to_owned_ref currently hardcodes
framing by calling wacore_binary::marshal and slicing bytes[1..]; change it to
use wacore_binary::marshal_auto(&node) and then pass the result through
wacore_binary::util::unpack(...) to obtain the raw protocol payload, and then
construct Arc::new(wacore_binary::OwnedNodeRef::new(unpacked).expect(...)). This
ensures node_to_owned_ref follows the production unpack contract and will stay
correct if framing/compression changes.

In `@src/handlers/notification.rs`:
- Around line 206-211: Several handlers currently call to_owned_node() which
forces allocations and prevents zero-copy; update the parsing/handler APIs to
accept a NodeRef<'_> (borrowed) and then pass &node (the existing NodeRef)
instead of calling to_owned_node() in call sites such as
crate::pair_code::handle_pair_code_notification, DeviceNotification::try_parse,
BusinessNotification::try_parse, and GroupNotification::try_from_node; change
those function signatures to take &NodeRef<'_> (or &impl AsNodeRef) and update
their internals to work with the borrowed node so you can remove the
to_owned_node() conversions across these notification handlers, tracking any
remaining unported call sites as follow-up work.

In `@src/message.rs`:
- Around line 3598-3599: The test code currently masks failures by using
unwrap_or(wacore_binary::jid::Server::Lid) after
wacore_binary::jid::Server::try_from(lid_server); replace those fallbacks with
expect(...) so the conversion failure fails loudly and surfaces regressions —
update the expression where Server::try_from(lid_server) is called (and the
other identical occurrences in this file) to call expect with a clear message
indicating the failed lid_server → Server mapping instead of defaulting to
Server::Lid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa2e0ffa-15b7-4579-9f3b-fd44f17885fd

📥 Commits

Reviewing files that changed from the base of the PR and between 951c6d5 and d4f7e86.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • Cargo.toml
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/features/media_reupload.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/receipt.rs
  • src/retry.rs
  • src/unified_session.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/messages.rs
  • wacore/src/types/events.rs

Comment on lines +240 to 241
self.clear_device_record(user, device.jid.server.as_str(), &record)
.await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't cross-product user aliases with both server variants.

resolve_lookup_keys() preserves which alias is LID vs PN, but delete_sessions_for_devices() throws that away via all_keys() and then loops both Server::Lid and Server::Pn. For a mapped user that purges four addresses, including mixed lid@s.whatsapp.net / pn@lid variants. Those are not the same account, and if a numeric LID collides with some real phone number, this cleanup can delete an unrelated Signal session. Match on UserLookupKeys and only purge valid (user, server) pairs; for the unknown case, use the caller's actual server instead of ignoring it in clear_device_record().

Suggested direction
match self.resolve_lookup_keys(user).await {
    UserLookupKeys::LidWithPn { lid, pn } | UserLookupKeys::PnWithLid { lid, pn } => {
        purge(&lid, wacore_binary::jid::Server::Lid, device_ids).await;
        purge(&pn, wacore_binary::jid::Server::Pn, device_ids).await;
    }
    UserLookupKeys::Unknown { user } => {
        purge(&user, server_from_caller, device_ids).await;
    }
}

Also applies to: 303-310

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 240 - 241, The cleanup currently
cross-products user aliases by calling all_keys() and iterating both Server::Lid
and Server::Pn, which throws away the LID/PN distinction from
resolve_lookup_keys() and can purge unrelated accounts; update
delete_sessions_for_devices() (and the similar block at 303-310) to match on
resolve_lookup_keys() (UserLookupKeys::LidWithPn, ::PnWithLid, ::Unknown) and
only call clear_device_record()/purge for the specific (user, server) pairs
returned (use the caller's server for the Unknown case) rather than iterating
both server variants or using all_keys(). Ensure clear_device_record() is
invoked with the exact (user, server) tuple from the match so you preserve the
original alias semantics.

Comment thread src/features/media_reupload.rs Outdated
Comment thread src/handlers/ib.rs Outdated
Comment thread src/handlers/receipt.rs Outdated
Comment thread src/retry.rs Outdated
Comment on lines 109 to +115
fn read_jid_pair(&mut self) -> Result<JidRef<'a>> {
let user_val = self.read_value_as_string()?;
let server = self.read_value_as_string()?.unwrap_or(Cow::Borrowed(""));
let server_str = self.read_value_as_string()?.unwrap_or(Cow::Borrowed(""));
let user = user_val.unwrap_or(Cow::Borrowed(""));
let server = crate::jid::Server::try_from(server_str.as_ref()).map_err(|_| {
BinaryError::AttrParse(format!("JID_PAIR unknown server: {}", server_str))
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add a regression test for invalid JID_PAIR servers.

Lines 111-115 are the new fail-fast behavior behind the Server enum migration, but the decoder tests never exercise it. A tiny crafted buffer asserting BinaryError::AttrParse here would keep the old Server::Pn fallback from creeping back in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 109 - 115, Add a regression test
that exercises the new fail-fast path in read_jid_pair: craft a minimal binary
buffer representing a JID_PAIR where the server string is invalid, feed it to
the decoder (the same decoding entry used in tests that ultimately calls
read_jid_pair) and assert the decode returns Err(BinaryError::AttrParse(..));
this ensures Server::try_from(...) in read_jid_pair fails and the AttrParse
error is produced rather than falling back to the old Server::Pn behavior.
Include references to read_jid_pair, Server::try_from, and
BinaryError::AttrParse in the test assertion so it fails if the old fallback
returns a valid Server instead of an error.

Comment thread wacore/src/iq/chatstate.rs Outdated
Comment on lines 140 to 145
let mut attrs = node.attr_parser();
let from = match attrs.optional_jid("from") {
Some(jid) => jid,
None => {
if optional_jid(node, "to")?.is_some() {
if attrs.optional_jid("to").is_some() {
return Err(ChatstateParseError::SelfEcho);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify optional_jid API shape
rg -n "fn optional_jid\\(" wacore/binary/src -C2

# Verify InvalidJid construction sites in this parser
rg -n "InvalidJid|ChatstateParseError::InvalidJid" wacore/src/iq/chatstate.rs -C3

Repository: jlucaso1/whatsapp-rust

Length of output: 1188


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get full implementation of optional_jid method
echo "=== Full optional_jid implementation ==="
rg -n "pub fn optional_jid" wacore/binary/src/attrs.rs -A 10

# Get the full parse function context around line 140-145
echo -e "\n=== Full parse function in chatstate.rs ==="
rg -n "fn parse" wacore/src/iq/chatstate.rs -A 50 | head -80

# Check if InvalidJid is ever constructed in chatstate.rs
echo -e "\n=== All ChatstateParseError construction sites ==="
rg "ChatstateParseError::" wacore/src/iq/chatstate.rs -n

Repository: jlucaso1/whatsapp-rust

Length of output: 3441


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if InvalidJid is constructed anywhere in the entire codebase
echo "=== InvalidJid construction across all files ==="
rg "ChatstateParseError::InvalidJid|InvalidJid\(" wacore/ -r

# Get the full ChatstateParseError enum definition
echo -e "\n=== ChatstateParseError enum definition ==="
rg -B2 "enum ChatstateParseError" wacore/src/iq/chatstate.rs -A 15

# Check if attr_parser().errors() is ever checked after optional_jid calls
echo -e "\n=== How errors from attr_parser are handled ==="
rg "attr_parser\(\).*errors\|\.errors()" wacore/src/iq/chatstate.rs -A 3 -B 3

Repository: jlucaso1/whatsapp-rust

Length of output: 545


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if InvalidJid is constructed anywhere in the entire codebase
echo "=== InvalidJid construction across all files ==="
rg "ChatstateParseError::InvalidJid|InvalidJid\(" wacore/

# Get the full ChatstateParseError enum definition
echo -e "\n=== ChatstateParseError enum definition ==="
rg -B2 "enum ChatstateParseError" wacore/src/iq/chatstate.rs -A 15

# Check if errors from attr_parser are checked after optional_jid calls
echo -e "\n=== Check attr_parser error handling ==="
rg "attr_parser\(\)" wacore/src/iq/chatstate.rs -A 25 | head -40

Repository: jlucaso1/whatsapp-rust

Length of output: 1826


InvalidJid is unreachable in parse().

optional_jid() swallows JID parse errors into the attrs.errors list and returns None for both missing and malformed attributes. Since the parser never checks attrs.errors, both cases collapse into MissingFrom. The InvalidJid variant exists in the enum but cannot be constructed, losing the error granularity it promises.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/chatstate.rs` around lines 140 - 145, The parse() path using
attrs.optional_jid("from") treats both missing and malformed JIDs the same
because optional_jid() records parse failures in attrs.errors and returns None,
making ChatstateParseError::InvalidJid unreachable; fix by distinguishing
malformed vs missing JID: after calling let mut attrs = node.attr_parser(); use
either a required-parsing helper (e.g., call attrs.jid("from") or similar) that
returns a parse error you can map to ChatstateParseError::InvalidJid, or, if you
keep optional_jid("from"), immediately inspect attrs.errors before returning
MissingFrom and return ChatstateParseError::InvalidJid when a JID parse error
for "from" is present (ensuring SelfEcho logic still checks
attrs.optional_jid("to") as before).

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from d4f7e86 to a82a3ea Compare April 11, 2026 16:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a82a3ea468

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pair.rs Outdated
.map(|from| from == SERVER_JID)
.unwrap_or(false)
{
if node.get_attr("from").and_then(|v| v.as_str()) != Some(SERVER_JID) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept JID-valued from when routing pairing IQs

ValueRef::as_str() returns None for ValueRef::Jid, so this guard now rejects IQ stanzas whose from attribute is decoded as a structured JID token. In that case handle_iq exits early and skips all pairing branches (pair-device, pair-success, etc.), which can break QR/login pairing flows depending on wire encoding. The previous comparison path handled both string and JID representations.

Useful? React with 👍 / 👎.

Comment thread wacore/src/pair.rs Outdated

/// Builds acknowledgment node for a pairing request from a NodeRef.
pub fn build_ack_node_ref(request_node: &NodeRef<'_>) -> Option<Node> {
let to = request_node.get_attr("from").and_then(|v| v.as_str())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build pair ACKs from JID-valued from attrs

This ACK builder now requires from to be a plain string via as_str(), but decoded attributes may be ValueRef::Jid for the same wire field. When that happens, build_ack_node_ref returns None and no IQ result ACK is sent for pairing requests, which can stall or fail pairing handshakes on JID-token encoded payloads.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from a82a3ea to 80febd5 Compare April 11, 2026 16:33
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review changes, check for dry imrovemnts, i think this PR has so much additions over remove, can we do things better? Also try to find dead code, like utils and functions that we need to aoid because of the new behaviour, this will cleanup code and make users less confusedo f what utilizies (also devs)

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from d523c68 to ff7c639 Compare April 12, 2026 00:34
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review things carefully

@claude

claude Bot commented Apr 12, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from ff7c639 to 2fe06ef Compare April 12, 2026 00:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
wacore/src/xml.rs (1)

67-74: ⚠️ Potential issue | 🟡 Minor

Inconsistent attribute ordering between Node and NodeRef formatting.

The Node::format_attributes implementation (lines 29-41) sorts attribute keys before formatting, but this NodeRef::format_attributes implementation iterates directly without sorting. This means the same logical node will produce different XML output depending on whether it's a Node or NodeRef, which could cause issues for tests, debugging, or any code expecting deterministic output.

Consider making the behavior consistent by sorting attributes here as well.

📋 Proposed fix to add sorting for consistent output
 fn format_attributes(&self, out: &mut String) {
     if self.attrs.is_empty() {
         return;
     }
-    for (key, value) in self.attrs.iter() {
+    let mut attrs_vec: Vec<_> = self.attrs.iter().collect();
+    attrs_vec.sort_unstable_by_key(|(k, _)| *k);
+    for (key, value) in attrs_vec {
         let _ = write!(out, " {}=\"{}\"", key, value);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/xml.rs` around lines 67 - 74, NodeRef::format_attributes currently
iterates self.attrs directly causing nondeterministic attribute ordering; make
it consistent with Node::format_attributes by collecting the keys, sorting them,
and then writing attributes in sorted order (e.g., mirror the sorting logic used
in Node::format_attributes when formatting into out) so both Node and NodeRef
produce deterministic, identical XML output for the same attributes.
wacore/src/iq/props.rs (1)

191-216: 🧹 Nitpick | 🔵 Trivial

Avoid trial-parsing both prop variants on the hot path.

AbPropConfig::try_from_node_ref currently attempts a full AbProp parse and then a full SamplingProp parse for the same node. That adds redundant attribute lookups for large props payloads, and it can also accept ambiguous mixed payloads instead of rejecting them explicitly. Dispatch off the discriminator attrs first, then parse exactly one variant.

♻️ Suggested refactor
     fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self, anyhow::Error> {
+        use crate::iq::node::optional_attr;
+
         if node.tag != "prop" {
             return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
         }
-
-        let experiment = AbProp::try_from_node_ref(node);
-        if let Ok(prop) = experiment {
-            return Ok(Self::Experiment(prop));
-        }
-
-        let sampling = SamplingProp::try_from_node_ref(node);
-        if let Ok(prop) = sampling {
-            return Ok(Self::Sampling(prop));
-        }
-
-        let experiment_err = experiment
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        let sampling_err = sampling
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        Err(anyhow::anyhow!(
-            "prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
-            experiment_err,
-            sampling_err
-        ))
+        match (
+            optional_attr(node, "config_code"),
+            optional_attr(node, "event_code"),
+        ) {
+            (Some(_), None) => AbProp::try_from_node_ref(node).map(Self::Experiment),
+            (None, Some(_)) => SamplingProp::try_from_node_ref(node).map(Self::Sampling),
+            (Some(_), Some(_)) => Err(anyhow::anyhow!(
+                "prop cannot contain both experiment and sampling attributes"
+            )),
+            (None, None) => Err(anyhow::anyhow!(
+                "prop missing discriminator attributes"
+            )),
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/props.rs` around lines 191 - 216, Refactor try_from_node_ref to
dispatch on discriminator attributes before parsing: inspect the node's
distinguishing attribute(s) (e.g., the attribute(s) that indicate experiment vs
sampling) and if they clearly indicate AbProp -> call
AbProp::try_from_node_ref(node) and return Self::Experiment on success; if they
indicate Sampling -> call SamplingProp::try_from_node_ref(node) and return
Self::Sampling on success; if both discriminators are present treat as ambiguous
and return a clear error, and if neither present return a "missing
discriminator" error. Ensure you remove the current pattern of invoking both
AbProp::try_from_node_ref and SamplingProp::try_from_node_ref unconditionally so
attribute lookups happen only once and mixed payloads are rejected explicitly.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Propagate malformed <host> entries instead of silently dropping them.

filter_map(... .ok()?) turns host-parse failures into omissions, so a partially invalid media_conn response now succeeds with a truncated or empty host list. That masks protocol regressions and can leave callers with no usable media endpoint even though the IQ parse reported success. MediaConnResponseExtended::try_from_node_ref already fails fast here; this path should do the same.

🐛 Suggested fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(|host_node| {
+                let ext = MediaConnHostExtended::try_from_node_ref(host_node)?;
+                Ok(MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<_, anyhow::Error>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The current parsing
silently drops malformed <host> entries because filter_map(... .ok()?) swallows
MediaConnHostExtended::try_from_node_ref errors; change the chain to map each
host_node -> MediaConnHostExtended::try_from_node_ref(host_node) and then map
Ok(ext) into MediaConnHost, collect into a Result<Vec<MediaConnHost>, _> (so use
collect::<Result<_, _>>() or equivalent) and propagate the error with ? so that
parsing fails fast (affecting the hosts construction where
media_conn_node.get_children_by_tag("host") is used and the
MediaConnHostExtended::try_from_node_ref call).
wacore/src/iq/prekeys.rs (1)

62-74: ⚠️ Potential issue | 🟠 Major

Don't coerce malformed integer payloads to 0.

This helper zero-fills missing/non-byte content and truncates oversized payloads. DigestKeyBundleSpec::parse_response uses it for required fields like <registration> and <skey><id>, so a corrupt server response becomes a valid-looking bundle instead of failing fast.

🛠️ Safer direction
-fn extract_content_uint(node: Option<&NodeRef<'_>>) -> u32 {
-    node.and_then(|n| match n.content.as_deref() {
-        Some(NodeContentRef::Bytes(b)) => {
-            let mut buf = [0u8; 4];
-            let len = b.len().min(4);
-            buf[4 - len..].copy_from_slice(&b[..len]);
-            Some(u32::from_be_bytes(buf))
-        }
-        _ => None,
-    })
-    .unwrap_or(0)
+fn extract_required_content_uint(
+    node: Option<&NodeRef<'_>>,
+    tag: &str,
+) -> Result<u32, anyhow::Error> {
+    let bytes = match node.and_then(|n| n.content.as_deref()) {
+        Some(NodeContentRef::Bytes(b)) if (1..=4).contains(&b.len()) => b,
+        _ => return Err(anyhow!("missing or invalid bytes in <{}>", tag)),
+    };
+    let mut buf = [0u8; 4];
+    buf[4 - bytes.len()..].copy_from_slice(bytes);
+    Ok(u32::from_be_bytes(buf))
 }
wacore/derive/src/lib.rs (1)

184-270: 🧹 Nitpick | 🔵 Trivial

Reuse one attr_parser() per generated parser.

The derive now emits node.attr_parser() once per field access. On larger stanzas that adds avoidable work to the decode hot path; generate a single local parser and read every attribute from it instead.

♻️ Generated shape to target
 fn try_from_node_ref(node: &::wacore_binary::node::NodeRef<'_>) -> ::anyhow::Result<Self> {
     if node.tag != `#tag` {
         return Err(::anyhow::anyhow!("expected <{}>, got <{}>", `#tag`, node.tag));
     }
+    let mut attrs = node.attr_parser();
     Ok(Self {
-        `#field_ident`: node.attr_parser().required_string(`#attr_name`)?.to_string(),
+        `#field_ident`: attrs.required_string(`#attr_name`)?.to_string(),
         #(`#field_parsers`),*
     })
 }

Also applies to: 329-335

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/derive/src/lib.rs` around lines 184 - 270, The generated code
currently calls node.attr_parser() for every attribute access (see match arms
that emit expressions using node.attr_parser()), which is wasteful; change the
generator to emit a single local let parser = node.attr_parser(); at the start
of the generated parser body and then replace all occurrences of
node.attr_parser() in the AttrType match arms with parser so every attribute
read (optional_jid, required_string, optional_string, optional_u64, etc.) uses
that single parser instance; ensure this same change is applied for the other
block referenced (lines 329-335) so both places reuse the local parser variable.
src/handlers/notification.rs (1)

1217-1296: 🧹 Nitpick | 🔵 Trivial

Hoist the group-cache lookup out of the action loop.

These notifications can batch multiple actions, but get_group_cache().await is still paid once per action. Pulling it up once per notification keeps this path closer to the perf goal of the PR.

♻️ Minimal diff
     let timestamp = i64::try_from(notification.timestamp)
         .ok()
         .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
         .unwrap_or_else(chrono::Utc::now);
+    let group_cache = client.get_group_cache().await;
 
     for action in notification.actions {
         // Granularly patch group cache instead of invalidating — matches WA Web's
         // addParticipantInfo / removeParticipantInfo pattern and avoids a
         // group metadata IQ round-trip.
         match &action {
             GroupNotificationAction::Add { participants, .. } => {
-                let group_cache = client.get_group_cache().await;
                 if let Some(mut info) = group_cache.get(&notification.group_jid).await {
                     let new: Vec<_> = participants
                         .iter()
                         .map(|p| (p.jid.clone(), p.phone_number.clone()))
                         .collect();
@@
             }
             GroupNotificationAction::Remove { participants, .. } => {
-                let group_cache = client.get_group_cache().await;
                 if let Some(mut info) = group_cache.get(&notification.group_jid).await {
                     let users: Vec<&str> =
                         participants.iter().map(|p| p.jid.user.as_str()).collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 1217 - 1296, The group-cache
lookup is performed inside the actions loop causing an unnecessary await per
action; in handle_group_notification, call client.get_group_cache().await once
before iterating notification.actions and reuse that group_cache variable inside
the match arms (refer to handle_group_notification, get_group_cache, and
notification.actions) so you remove the per-action awaits and still call
group_cache.get(...).await / group_cache.insert(...).await as before; ensure the
moved variable is in scope for both Add and Remove arms and adjust mutability if
needed.
src/retry.rs (1)

551-567: 🧹 Nitpick | 🔵 Trivial

Deduplicate the registration-ID decoding.

The variable-length big-endian parsing now exists in three places (extract_registration_id_from_node, extract_registration_id_from_node_ref, and this closure). Pull it into one helper so the test-only owned path and the production NodeRef path cannot drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 551 - 567, The registration-ID decoding logic is
duplicated across extract_registration_id_from_node,
extract_registration_id_from_node_ref, and the closure that maps
registration_node.and_then(get_bytes_content_ref); extract that variable-length
big-endian parse into a single helper (e.g., decode_registration_id_from_bytes
or parse_be_u32_varlen) that takes a &[u8] and returns a u32, then replace the
logic in extract_registration_id_from_node,
extract_registration_id_from_node_ref, and the registration_node mapping to call
that helper (use get_bytes_content_ref to obtain the &[u8] before calling the
helper).
src/client.rs (2)

2272-2317: ⚠️ Potential issue | 🟠 Major

ACK waiters still re-encode and re-parse the stanza.

Lines 2303-2308 marshal the borrowed node back into bytes, strip the format byte manually, and parse it again into OwnedNodeRef. ACKs are on a common send path, so this adds avoidable CPU/allocation churn and duplicates framing knowledge in bytes[1..]. Thread the original Arc<OwnedNodeRef> into handle_ack_response and forward it directly, like the IQ waiter path already does.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 2272 - 2317, handle_ack_response is re-encoding
the borrowed NodeRef then reparsing into an OwnedNodeRef, causing unnecessary
CPU/allocations; change handle_ack_response to accept and forward the original
Arc<wacore_binary::OwnedNodeRef> (or an Arc-wrapped owned ref) instead of a
borrowed NodeRef, remove the marshal_ref/bytes[1..] re-encode path, and send
that Arc directly to the waiter (the same pattern as the IQ waiter). Update the
caller(s) that invoke handle_ack_response to pass the Arc<OwnedNodeRef> they
already have, and keep the response_waiters removal/send logic using
waiter.send(arc_onr) so no framing/encoding is duplicated.

1503-1514: ⚠️ Potential issue | 🟠 Major

into_owned() puts a full frame copy back on the receive hot path.

When util::unpack() returns a borrowed slice for an uncompressed stanza, Line 1512 clones the entire payload anyway. That reintroduces O(n) allocation/copy work in the common case and undercuts the zero-copy decode goal. Handle the borrowed branch by reusing or mutating decrypted_payload instead of always materializing a new Vec.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1503 - 1514, The current code always calls
into_owned() on unpacked_data_cow which clones the frame; instead match
unpacked_data_cow and avoid cloning when it's a borrowed slice: if
unpacked_data_cow is Cow::Borrowed(_), reuse or mutate the existing
decrypted_payload Vec (consume decrypted_payload and adjust/truncate it to the
borrowed slice bytes) and pass that Vec into wacore_binary::OwnedNodeRef::new;
if it's Cow::Owned(vec) only then use that owned vec (or into_owned())—update
the logic around unpacked_data_cow, decrypted_payload, and the call to
wacore_binary::OwnedNodeRef::new to select the appropriate Vec without an
unnecessary copy.
♻️ Duplicate comments (5)
src/handlers/receipt.rs (1)

28-28: 🧹 Nitpick | 🔵 Trivial

Move node into handle_receipt instead of cloning it.

The earlier type mismatch is fixed, but this still does an unnecessary Arc refcount bump on every receipt even though node is never used again after the call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/receipt.rs` at line 28, The call currently does an unnecessary
Arc refcount bump: replace client.handle_receipt(Arc::clone(&node)).await with a
move of the Arc (client.handle_receipt(node).await) and update handle_receipt's
signature to accept the Arc by value (e.g., fn handle_receipt(&self, node:
Arc<Node>) / async fn handle_receipt(&self, node: Arc<Node>) as appropriate) so
the Arc is moved instead of cloned; also remove any later uses of `node` in this
scope (or recreate it if needed) so there are no borrow/use-after-move issues.
src/handlers/ib.rs (1)

113-143: 🧹 Nitpick | 🔵 Trivial

Collapse the routing_info extraction into a let-chain.

This reintroduces the nested if let pattern the repo explicitly avoids. Bind routing_info_node and routing_bytes in one condition and keep the fallback logging in the else branches.

♻️ Suggested refactor
-                if let Some(routing_info_node) = child.get_optional_child("routing_info") {
-                    if let Some(NodeContentRef::Bytes(routing_bytes)) =
-                        routing_info_node.content.as_deref()
-                    {
+                let routing_info_node = child.get_optional_child("routing_info");
+                if let Some(routing_info_node) = routing_info_node
+                    && let Some(NodeContentRef::Bytes(routing_bytes)) =
+                        routing_info_node.content.as_deref()
+                {
                         if !routing_bytes.is_empty() {
                             debug!(
                                 "Received edge routing info ({} bytes), storing for reconnection",
                                 routing_bytes.len()
                             );
                             // Spawn to avoid blocking the read loop on Device write lock.
                             let routing_bytes = routing_bytes.to_vec();
                             let client_clone = client.clone();
                             client
                                 .runtime
                                 .spawn(Box::pin(async move {
                                     client_clone
                                         .persistence_manager
                                         .modify_device(|device| {
                                             device.edge_routing_info = Some(routing_bytes);
                                         })
                                         .await;
                                 }))
                                 .detach();
                         } else {
                             debug!("Received empty edge routing info, ignoring");
                         }
-                    } else {
-                        debug!("Edge routing info node has no bytes content");
-                    }
-                } else {
-                    debug!("Edge routing stanza has no routing_info child");
-                }
+                } else if routing_info_node.is_none() {
+                    debug!("Edge routing stanza has no routing_info child");
+                } else {
+                    debug!("Edge routing info node has no bytes content");
+                }

As per coding guidelines "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 113 - 143, Collapse the nested if-let into a
single let-chain: combine the checks for routing_info_node and routing_bytes
using `if let Some(routing_info_node) = child.get_optional_child("routing_info")
&& let Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... }`, keep the existing logic that
checks for empty bytes and spawns the async task (referencing
client.runtime.spawn, client.persistence_manager.modify_device, and
device.edge_routing_info) inside the true branch, and preserve the two fallback
debug logs in the appropriate else branches (one for missing routing_info child
and one for non-bytes/empty content).
src/client/device_registry.rs (1)

301-313: ⚠️ Potential issue | 🟠 Major

Only purge valid (user, server) pairs here.

This still cross-products lookup.all_keys() with both Server::Lid and Server::Pn, so a mapped user now purges synthetic addresses like pn@lid and lid@s.whatsapp.net. That can delete unrelated Signal sessions, and it also ignores the caller’s actual server for UserLookupKeys::Unknown. Match on UserLookupKeys and emit only the real pairs instead.

♻️ Suggested direction
-    async fn delete_sessions_for_devices(&self, user: &str, device_ids: &[u16]) {
+    async fn delete_sessions_for_devices(
+        &self,
+        user: &str,
+        unknown_server: wacore_binary::Server,
+        device_ids: &[u16],
+    ) {
         let lookup = self.resolve_lookup_keys(user).await;
-        let servers = [wacore_binary::Server::Lid, wacore_binary::Server::Pn];
-        for server in servers {
-            for key in lookup.all_keys() {
-                for &device_id in device_ids {
-                    let mut jid = Jid::new(key, server);
-                    jid.device = device_id;
-                    let addr = wacore::types::jid::JidExt::to_protocol_address(&jid);
-                    self.signal_cache.delete_session(&addr).await;
-                }
-            }
-        }
+        let aliases: Vec<(String, wacore_binary::Server)> = match lookup {
+            UserLookupKeys::LidWithPn { lid, pn } | UserLookupKeys::PnWithLid { lid, pn } => vec![
+                (lid, wacore_binary::Server::Lid),
+                (pn, wacore_binary::Server::Pn),
+            ],
+            UserLookupKeys::Unknown { user } => vec![(user, unknown_server)],
+        };
+
+        for (key, server) in aliases {
+            for &device_id in device_ids {
+                let mut jid = Jid::new(&key, server);
+                jid.device = device_id;
+                let addr = wacore::types::jid::JidExt::to_protocol_address(&jid);
+                self.signal_cache.delete_session(&addr).await;
+            }
+        }
         self.flush_signal_cache_logged("delete_sessions_for_devices", None)
             .await;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 301 - 313, The current
delete_sessions_for_devices blindly cross-products lookup.all_keys() with both
Server::Lid and Server::Pn, causing invalid (user,server) pairs to be purged;
change delete_sessions_for_devices to match on each lookup key (from
resolve_lookup_keys / UserLookupKeys) and emit only the actual server(s) for
that key (e.g., map Lid->Server::Lid, Pn->Server::Pn, and for
UserLookupKeys::Unknown use the caller-provided server), then construct Jid with
that single server and call self.signal_cache.delete_session(&addr).await for
each device_id — do not iterate the fixed servers array.
wacore/src/iq/chatstate.rs (1)

140-145: ⚠️ Potential issue | 🟡 Minor

InvalidJid is still unreachable here.

optional_jid("from") returns None for both missing and malformed values, and this parser never inspects the accumulated attr errors, so bad from JIDs still collapse into MissingFrom / SelfEcho. This is the same issue raised in the previous review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/chatstate.rs` around lines 140 - 145, The current parsing for
`from` in the chatstate parsing block uses `attrs.optional_jid("from")` which
returns None for both missing and malformed JIDs, so malformed JIDs are being
treated as `MissingFrom`/`SelfEcho`; change the logic around the `let from =
match attrs.optional_jid("from") { ... }` in `chatstate.rs` to detect and map
malformed JIDs to `ChatstateParseError::InvalidJid` instead of collapsing them:
either use the attribute parser API that returns a Result (e.g.,
`attrs.jid("from")` or `attrs.require_jid("from")`) so you can match an
`Err(InvalidJid)` and return `ChatstateParseError::InvalidJid`, or, if only
`optional_jid` is available, inspect the parser’s accumulated attribute errors
(e.g., `attrs.errors()` or equivalent) after calling `optional_jid("from")` and
return `ChatstateParseError::InvalidJid` when a malformed- JID error for the
"from" attribute is present; ensure the `SelfEcho`/`MissingFrom` branches remain
for the genuinely missing case.
src/client.rs (1)

3640-3653: 🛠️ Refactor suggestion | 🟠 Major

ACK building still stringifies typed attrs.

Lines 3641-3653 run from / participant / type through to_string_cow() before wrapping them back into NodeValue, so JID-backed attrs still pay the string round-trip on every ackable stanza. Preserve the borrowed value/JID directly if the NodeValue API supports it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3640 - 3653, In build_ack_node, avoid
stringifying JID-backed attributes by removing the to_string_cow() round-trip
for "from", "participant" and the "type" branch; instead construct NodeValue
directly from the borrowed attribute/JID returned by node.get_attr(...) (using
the NodeValue API overload that accepts the borrowed/cow or Jid-backed value) so
you preserve the original borrowed/JID representation and eliminate unnecessary
allocations when building ack nodes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/handlers/notification.rs`:
- Around line 557-560: The current mapping silently truncates key-index with `v
as u32`, so change the conversion in the AccountSyncDevice construction (the
closure using attr_parser().optional_u64("key-index")) to use fallible
conversion: replace the cast with a try conversion like u32::try_from(v).ok() so
overflowing u64 values become None and are safely ignored by the surrounding
filter_map; keep the rest of the AccountSyncDevice fields (jid via optional_jid)
unchanged.

In `@src/message.rs`:
- Around line 376-377: The hotspot is that (*enc_node).to_owned()
re-materializes the full <enc> payload (enc_node_owned) before calling custom
enc handlers; instead change the handler API to accept a reference (e.g.,
&NodeRef or &OwnedNodeRef) so we can pass a borrow and avoid allocation.
Concretely: update the enc handler trait/signature to take &NodeRef (or
&OwnedNodeRef) instead of owning Node, update all implementations/call-sites to
accept the borrowed type, and remove the to_owned() allocation at the call site
(use a borrow of enc_node instead). Ensure any places that previously relied on
ownership either clone only when strictly required or adjust to OwnedNodeRef for
explicit ownership.

In `@src/pair_code.rs`:
- Around line 238-245: The branch that extracts the wrapped ephemeral currently
returns a Vec (primary_wrapped_ephemeral) even though you already require
exactly 80 bytes; change it to produce a fixed-size array [u8; 80] instead of
Vec<u8>. Locate the match on
reg_node.get_optional_child_by_tag(...).and_then(|n| match n.content.as_deref()
{ Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()), _ =>
None, }) and replace the Some(b.to_vec()) path with code that converts the slice
into a [u8; 80] (e.g., use try_into() on b and propagate None on failure) so
primary_wrapped_ephemeral becomes a [u8; 80] and can be passed directly to the
downstream blocking decrypt call without heap allocation.

In `@src/pair.rs`:
- Around line 54-56: The code is allocating early with
String::from_utf8(bytes.to_vec()); instead, keep it zero-copy by validating
UTF-8 with std::str::from_utf8 on the borrowed bytes and only allocate at the
end if needed. Replace the pattern in the NodeContentRef::Bytes match (the
grandchild.content branch) to call std::str::from_utf8(bytes.as_ref()) (or
bytes.as_slice()) and then, only on Ok(r), convert r to an owned String (e.g.,
r.to_string() or String::from(r)) where the owned value is actually required.
- Around line 201-206: The current code silently replaces missing or malformed
device jid/lid with Jid::default() by using
optional_jid(...).unwrap_or_default(), which allows invalid pair-success
messages to proceed and persist empty identifiers; change the logic in the
pair-success handling (the block that calls
success_node.get_optional_child_by_tag("device") and uses
device_node.attr_parser() to parse "jid" and "lid") to validate both attributes
strictly: attempt to parse each with optional_jid (or the parser's parsing
method) and if either returns None or an Err, immediately return an error
rejecting the pair-success instead of defaulting, preventing issuing
SetId/SetLid commands with empty JIDs and matching the strict validation used
for device-identity.

In `@src/test_utils.rs`:
- Around line 8-10: The code in node_to_owned_ref currently allocates a second
buffer with bytes[1..].to_vec(); instead remove the leading format byte in-place
to avoid the extra allocation: assert that bytes[0] == 0x00 (or use an explicit
expect message), call bytes.remove(0) to strip the first byte, then pass the
mutated bytes into OwnedNodeRef::new (keep the existing expect on
OwnedNodeRef::new). Refer to marshal (wacore_binary::marshal::marshal),
node_to_owned_ref, and OwnedNodeRef::new when making this change.

In `@wacore/appstate/src/patch_decode.rs`:
- Around line 89-99: The two zero-copy entry points parse_patch_list_ref and
parse_patch_lists_ref currently call node.to_owned() which allocates and defeats
zero-copy; change them to parse directly from &NodeRef by either (a) refactoring
parse_patch_list and parse_patch_lists to accept &NodeRef<'_> (update their
signatures and callers) or (b) add internal helper functions (e.g.,
parse_patch_list_from_ref and parse_patch_lists_from_ref) that take &NodeRef<'_>
and reuse the existing parsing logic (calling the existing parse_* variants if
necessary) so you can remove the .to_owned() calls and parse without cloning the
node tree. Ensure all uses of Node methods (get_optional_child_by_tag,
get_optional_child, children) operate on NodeRef.

In `@wacore/binary/src/jid.rs`:
- Around line 508-512: The actual_agent() method currently only treats
Server::Pn as agent-less; update it so that it returns 0 when self.server is any
of the agent-less variants (Server::Pn, Server::Lid, Server::Hosted,
Server::HostedLid) and otherwise returns self.agent; modify the match in
actual_agent() to include those additional Server variants mapping to 0 to
prevent hidden agent state on manually-constructed JIDs.

In `@wacore/src/appstate_sync.rs`:
- Around line 98-156: The decode_patch_list_ref and decode_patch_list functions
duplicate the snapshot and external_mutations download/hydration flow; refactor
by extracting that shared logic into a single helper (e.g., process_patch_lists)
that accepts a mutable PatchList (or a mutable ref wrapper) plus the download
callback and validate_macs flag, performs the snapshot_ref download/decoding and
per-patch external_mutations download/decoding (preserving the existing log
behavior and patch.mutations replacement), and then calls
self.process_patch_list(pl, validate_macs). Update decode_patch_list_ref and
decode_patch_list to build/obtain the PatchList, call the new helper, and return
its Result to avoid drift between the two code paths.

In `@wacore/src/iq/blocklist.rs`:
- Around line 116-131: The parse_response implementation duplicates
BlocklistResponse's parsing logic; replace the manual <list>/<item> traversal
with a call to BlocklistResponse::try_from_node_ref(response), propagate or map
its Result into the expected Ok(entries) shape, and preserve the existing warn
behavior on parse error (e.g., warn! target: "blocklist" with the error) so
failures are logged the same way; update references in fn parse_response to use
BlocklistResponse::try_from_node_ref instead of
BlocklistEntry::try_from_node_ref and collect entries from the returned
BlocklistResponse.

In `@wacore/src/iq/business.rs`:
- Around line 55-59: The match arm handling NodeContentRef::Bytes in node_text
performs an extra allocation by cloning bytes into a Vec before UTF-8 checking;
instead, validate the borrowed byte slice directly (use std::str::from_utf8 on
the &\[u8\]) and then allocate once by converting the resulting &str to a String
(e.g., call to_string()/to_owned() on the validated &str), replacing the
String::from_utf8(b.to_vec()).ok() expression in the node_text function.

In `@wacore/src/iq/groups.rs`:
- Around line 379-382: The current code defaults unknown participant types to
ParticipantType::Member which silently downgrades privileges; change the
participant_type binding so that if attrs.optional_string("type") is None you
keep ParticipantType::Member, but if it is Some(s) you must call
ParticipantType::try_from(s.as_ref()) and propagate or return an error when
try_from fails instead of using unwrap_or; update the code that defines
participant_type (the attrs.optional_string / ParticipantType::try_from usage)
to return a parse error (or Result::Err) on unknown values so unknown types are
not coerced to Member.

In `@wacore/src/iq/node.rs`:
- Around line 22-25: Change required_attr to return Result<Cow<'_, str>,
anyhow::Error> instead of String and avoid the eager to_string allocation: keep
the map from node.get_attr(key) but map the borrowed &str into Cow::Borrowed (or
use Cow::from) rather than calling to_string, and keep the same ok_or_else(...)
error path; update the function signature to pub(crate) fn required_attr(node:
&NodeRef<'_>, key: &str) -> Result<Cow<'_, str>, anyhow::Error> and add use
std::borrow::Cow; also update any callers that expect String to accept or
convert the Cow.

In `@wacore/src/iq/tctoken.rs`:
- Around line 281-287: The code is allocating and reparsing the JID string from
token_node by calling get_attr(...).to_string_cow().into_owned().parse();
instead use the typed attribute path ValueRef::to_jid() to avoid the extra
allocation/parse. Replace the get_attr -> to_string_cow -> parse flow with
token_node.get_attr("jid").and_then(|v| v.to_jid()) (or the equivalent API) so
you obtain a Jid directly and propagate the same error handling (convert missing
attribute to the existing "missing required attribute jid" error and map to the
same "invalid jid" style error when to_jid fails) while keeping symbols
token_node, get_attr, and Jid referenced.

In `@wacore/src/iq/usync.rs`:
- Around line 121-128: The current parsing in parse_lid_jid uses
attr_parser().optional_string("val").and_then(|val| val.parse::<Jid>().ok())
which forces allocation and re-parsing when the decoder already provides a Jid;
replace that pattern with attr_parser().optional_jid("val") to avoid needless
to_string/parse. Apply the same change to the other occurrences mentioned (the
similar parsing blocks around the parse user/jid logic at the other sites
referenced) so they call optional_jid("...") instead of
optional_string(...).and_then(|s| s.parse::<Jid>().ok()), keeping return types
as Option<Jid>.

In `@wacore/src/pair_code.rs`:
- Around line 32-34: Replace string-based SERVER_JID usage with the typed Server
enum: import wacore_binary::Server and change occurrences of
SERVER_JID.to_string() in the pair-code IQ builders to use Server::Pn (pass the
enum value instead of the string). Update the builder calls that currently
expect a String/&str to accept Server::Pn (or call .to_string() only if the API
strictly requires a String) — adjust the function signature or overloads where
needed so functions like the pair-code IQ constructors accept Server::Pn instead
of the SERVER_JID string constant.

In `@wacore/src/pair.rs`:
- Around line 85-98: build_ack_node_ref duplicates build_ack_node; extract a
single private builder that takes (to: &str, id: &str) and returns the Node,
then have both build_ack_node_ref and build_ack_node call that shared helper.
Implement something like a private fn (e.g., build_ack_inner or build_ack_for)
that constructs the Node via NodeBuilder using the provided &strs, and update
build_ack_node_ref to map attrs to &str and pass them through, and update
build_ack_node to forward its owned Strings to the same helper (converting to
&str as needed) so there is a single place to change the ACK shape.

In `@wacore/src/prekeys.rs`:
- Around line 144-147: The parser currently calls to_vec() on
NodeContentRef::Bytes (in extract_bytes_ref and similar branches) which
allocates; instead return a borrowed slice (e.g., Result<&[u8], anyhow::Error>
from extract_bytes_ref or add an extract_bytes_ref_slice that returns &[u8]) and
propagate that borrowed slice through the registration, identity key, prekey,
and signature parsing code paths; only at the final boundary, when you must
produce a fixed-size array, validate the slice length and copy into the stack
array (e.g., using try_into().map_err(...) and copying into [u8; N]) so no
intermediate Vec is allocated. Ensure you update call sites that expect Vec<u8>
to accept &[u8] or perform the single copy at the end in functions handling
registration, identity keys, prekeys, and signatures.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 2272-2317: handle_ack_response is re-encoding the borrowed NodeRef
then reparsing into an OwnedNodeRef, causing unnecessary CPU/allocations; change
handle_ack_response to accept and forward the original
Arc<wacore_binary::OwnedNodeRef> (or an Arc-wrapped owned ref) instead of a
borrowed NodeRef, remove the marshal_ref/bytes[1..] re-encode path, and send
that Arc directly to the waiter (the same pattern as the IQ waiter). Update the
caller(s) that invoke handle_ack_response to pass the Arc<OwnedNodeRef> they
already have, and keep the response_waiters removal/send logic using
waiter.send(arc_onr) so no framing/encoding is duplicated.
- Around line 1503-1514: The current code always calls into_owned() on
unpacked_data_cow which clones the frame; instead match unpacked_data_cow and
avoid cloning when it's a borrowed slice: if unpacked_data_cow is
Cow::Borrowed(_), reuse or mutate the existing decrypted_payload Vec (consume
decrypted_payload and adjust/truncate it to the borrowed slice bytes) and pass
that Vec into wacore_binary::OwnedNodeRef::new; if it's Cow::Owned(vec) only
then use that owned vec (or into_owned())—update the logic around
unpacked_data_cow, decrypted_payload, and the call to
wacore_binary::OwnedNodeRef::new to select the appropriate Vec without an
unnecessary copy.

In `@src/handlers/notification.rs`:
- Around line 1217-1296: The group-cache lookup is performed inside the actions
loop causing an unnecessary await per action; in handle_group_notification, call
client.get_group_cache().await once before iterating notification.actions and
reuse that group_cache variable inside the match arms (refer to
handle_group_notification, get_group_cache, and notification.actions) so you
remove the per-action awaits and still call group_cache.get(...).await /
group_cache.insert(...).await as before; ensure the moved variable is in scope
for both Add and Remove arms and adjust mutability if needed.

In `@src/retry.rs`:
- Around line 551-567: The registration-ID decoding logic is duplicated across
extract_registration_id_from_node, extract_registration_id_from_node_ref, and
the closure that maps registration_node.and_then(get_bytes_content_ref); extract
that variable-length big-endian parse into a single helper (e.g.,
decode_registration_id_from_bytes or parse_be_u32_varlen) that takes a &[u8] and
returns a u32, then replace the logic in extract_registration_id_from_node,
extract_registration_id_from_node_ref, and the registration_node mapping to call
that helper (use get_bytes_content_ref to obtain the &[u8] before calling the
helper).

In `@wacore/derive/src/lib.rs`:
- Around line 184-270: The generated code currently calls node.attr_parser() for
every attribute access (see match arms that emit expressions using
node.attr_parser()), which is wasteful; change the generator to emit a single
local let parser = node.attr_parser(); at the start of the generated parser body
and then replace all occurrences of node.attr_parser() in the AttrType match
arms with parser so every attribute read (optional_jid, required_string,
optional_string, optional_u64, etc.) uses that single parser instance; ensure
this same change is applied for the other block referenced (lines 329-335) so
both places reuse the local parser variable.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 375-385: The current parsing silently drops malformed <host>
entries because filter_map(... .ok()?) swallows
MediaConnHostExtended::try_from_node_ref errors; change the chain to map each
host_node -> MediaConnHostExtended::try_from_node_ref(host_node) and then map
Ok(ext) into MediaConnHost, collect into a Result<Vec<MediaConnHost>, _> (so use
collect::<Result<_, _>>() or equivalent) and propagate the error with ? so that
parsing fails fast (affecting the hosts construction where
media_conn_node.get_children_by_tag("host") is used and the
MediaConnHostExtended::try_from_node_ref call).

In `@wacore/src/iq/props.rs`:
- Around line 191-216: Refactor try_from_node_ref to dispatch on discriminator
attributes before parsing: inspect the node's distinguishing attribute(s) (e.g.,
the attribute(s) that indicate experiment vs sampling) and if they clearly
indicate AbProp -> call AbProp::try_from_node_ref(node) and return
Self::Experiment on success; if they indicate Sampling -> call
SamplingProp::try_from_node_ref(node) and return Self::Sampling on success; if
both discriminators are present treat as ambiguous and return a clear error, and
if neither present return a "missing discriminator" error. Ensure you remove the
current pattern of invoking both AbProp::try_from_node_ref and
SamplingProp::try_from_node_ref unconditionally so attribute lookups happen only
once and mixed payloads are rejected explicitly.

In `@wacore/src/xml.rs`:
- Around line 67-74: NodeRef::format_attributes currently iterates self.attrs
directly causing nondeterministic attribute ordering; make it consistent with
Node::format_attributes by collecting the keys, sorting them, and then writing
attributes in sorted order (e.g., mirror the sorting logic used in
Node::format_attributes when formatting into out) so both Node and NodeRef
produce deterministic, identical XML output for the same attributes.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 3640-3653: In build_ack_node, avoid stringifying JID-backed
attributes by removing the to_string_cow() round-trip for "from", "participant"
and the "type" branch; instead construct NodeValue directly from the borrowed
attribute/JID returned by node.get_attr(...) (using the NodeValue API overload
that accepts the borrowed/cow or Jid-backed value) so you preserve the original
borrowed/JID representation and eliminate unnecessary allocations when building
ack nodes.

In `@src/client/device_registry.rs`:
- Around line 301-313: The current delete_sessions_for_devices blindly
cross-products lookup.all_keys() with both Server::Lid and Server::Pn, causing
invalid (user,server) pairs to be purged; change delete_sessions_for_devices to
match on each lookup key (from resolve_lookup_keys / UserLookupKeys) and emit
only the actual server(s) for that key (e.g., map Lid->Server::Lid,
Pn->Server::Pn, and for UserLookupKeys::Unknown use the caller-provided server),
then construct Jid with that single server and call
self.signal_cache.delete_session(&addr).await for each device_id — do not
iterate the fixed servers array.

In `@src/handlers/ib.rs`:
- Around line 113-143: Collapse the nested if-let into a single let-chain:
combine the checks for routing_info_node and routing_bytes using `if let
Some(routing_info_node) = child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... }`, keep the existing logic that
checks for empty bytes and spawns the async task (referencing
client.runtime.spawn, client.persistence_manager.modify_device, and
device.edge_routing_info) inside the true branch, and preserve the two fallback
debug logs in the appropriate else branches (one for missing routing_info child
and one for non-bytes/empty content).

In `@src/handlers/receipt.rs`:
- Line 28: The call currently does an unnecessary Arc refcount bump: replace
client.handle_receipt(Arc::clone(&node)).await with a move of the Arc
(client.handle_receipt(node).await) and update handle_receipt's signature to
accept the Arc by value (e.g., fn handle_receipt(&self, node: Arc<Node>) / async
fn handle_receipt(&self, node: Arc<Node>) as appropriate) so the Arc is moved
instead of cloned; also remove any later uses of `node` in this scope (or
recreate it if needed) so there are no borrow/use-after-move issues.

In `@wacore/src/iq/chatstate.rs`:
- Around line 140-145: The current parsing for `from` in the chatstate parsing
block uses `attrs.optional_jid("from")` which returns None for both missing and
malformed JIDs, so malformed JIDs are being treated as `MissingFrom`/`SelfEcho`;
change the logic around the `let from = match attrs.optional_jid("from") { ...
}` in `chatstate.rs` to detect and map malformed JIDs to
`ChatstateParseError::InvalidJid` instead of collapsing them: either use the
attribute parser API that returns a Result (e.g., `attrs.jid("from")` or
`attrs.require_jid("from")`) so you can match an `Err(InvalidJid)` and return
`ChatstateParseError::InvalidJid`, or, if only `optional_jid` is available,
inspect the parser’s accumulated attribute errors (e.g., `attrs.errors()` or
equivalent) after calling `optional_jid("from")` and return
`ChatstateParseError::InvalidJid` when a malformed- JID error for the "from"
attribute is present; ensure the `SelfEcho`/`MissingFrom` branches remain for
the genuinely missing case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5590e549-0384-4596-a748-75ac203f9412

📥 Commits

Reviewing files that changed from the base of the PR and between 951c6d5 and ff7c639.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (116)
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs

Comment on lines 557 to 560
.filter_map(|n| {
let jid = n.attrs().optional_jid("jid")?;
let key_index = n.attrs().optional_u64("key-index").map(|v| v as u32);
let jid = n.attr_parser().optional_jid("jid")?;
let key_index = n.attr_parser().optional_u64("key-index").map(|v| v as u32);
Some(AccountSyncDevice { jid, key_index })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the specific file and lines mentioned
cat -n src/handlers/notification.rs | sed -n '549,570p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1008


🏁 Script executed:

# Also check the broader context to understand parse_account_sync_device_list function
rg -B 10 -A 15 "parse_account_sync_device_list" src/handlers/notification.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 6006


🏁 Script executed:

# Search for how key-index is used elsewhere in the codebase
rg "key-index" --type rs -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Check usync.rs to understand the intended behavior from the retrieved learning
rg -B 5 -A 10 "filter_devices_by_key_index|existing_key_indices" src/usync.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2887


Use u32::try_from(v) to reject overflowing key-index values instead of silently truncating.

The cast v as u32 silently wraps when the server sends a value exceeding u32::MAX. Since key_index is used in Signal protocol key management, a truncated value could cause the client to derive or use the wrong encryption key, leading to device pairing failures or security issues. Replace with u32::try_from(v).ok() to return None for invalid wire data, allowing safe default handling instead of corrupting device state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 557 - 560, The current mapping
silently truncates key-index with `v as u32`, so change the conversion in the
AccountSyncDevice construction (the closure using
attr_parser().optional_u64("key-index")) to use fallible conversion: replace the
cast with a try conversion like u32::try_from(v).ok() so overflowing u64 values
become None and are safely ignored by the surrounding filter_map; keep the rest
of the AccountSyncDevice fields (jid via optional_jid) unchanged.

Comment thread src/message.rs
Comment thread src/pair_code.rs
Comment on lines 238 to +245
let primary_wrapped_ephemeral = match reg_node
.get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
.and_then(|n| n.content.as_ref())
{
Some(NodeContent::Bytes(b)) if b.len() == 80 => b.clone(),
_ => {
.and_then(|n| match n.content.as_deref() {
Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
_ => None,
}) {
Some(b) => b,
None => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Parse the wrapped ephemeral into a fixed array instead of a Vec.

This branch already validates a hard 80-byte payload, so allocating a Vec here adds avoidable heap traffic on a perf-oriented path. A [u8; 80] keeps the copy stack-backed and still passes cleanly into the blocking decrypt call.

♻️ Proposed change
-    let primary_wrapped_ephemeral = match reg_node
+    let primary_wrapped_ephemeral: [u8; 80] = match reg_node
         .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
         .and_then(|n| match n.content.as_deref() {
-            Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
+            Some(NodeContentRef::Bytes(b)) => b.as_ref().try_into().ok(),
             _ => None,
         }) {
         Some(b) => b,
         None => {
             warn!(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair_code.rs` around lines 238 - 245, The branch that extracts the
wrapped ephemeral currently returns a Vec (primary_wrapped_ephemeral) even
though you already require exactly 80 bytes; change it to produce a fixed-size
array [u8; 80] instead of Vec<u8>. Locate the match on
reg_node.get_optional_child_by_tag(...).and_then(|n| match n.content.as_deref()
{ Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()), _ =>
None, }) and replace the Some(b.to_vec()) path with code that converts the slice
into a [u8; 80] (e.g., use try_into() on b and propagate None on failure) so
primary_wrapped_ephemeral becomes a [u8; 80] and can be passed directly to the
downstream blocking decrypt call without heap allocation.

Comment thread src/pair.rs Outdated
Comment thread src/pair.rs
Comment on lines 201 to +206
let (jid, lid) = if let Some(device_node) = success_node.get_optional_child_by_tag(&["device"])
{
let mut parser = device_node.attrs();
let mut parser = device_node.attr_parser();
let parsed_jid = parser.optional_jid("jid").unwrap_or_default();
let parsed_lid = parser.optional_jid("lid").unwrap_or_default();

if let Err(e) = parser.finish() {
warn!(target: "Client/Pair", "Error parsing device node attributes: {e:?}");
(Jid::default(), Jid::default())
} else {
(parsed_jid, parsed_lid)
}
(parsed_jid, parsed_lid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and locate src/pair.rs
git ls-files | grep -E "pair\.rs"

Repository: jlucaso1/whatsapp-rust

Length of output: 96


🏁 Script executed:

# Now let's examine the specific lines in src/pair.rs
cat -n src/pair.rs | head -220 | tail -40

Repository: jlucaso1/whatsapp-rust

Length of output: 1730


🏁 Script executed:

# Get more context around the pair-success handling
cat -n src/pair.rs | grep -A 10 -B 10 "pair-success"

Repository: jlucaso1/whatsapp-rust

Length of output: 3339


🏁 Script executed:

# Search for other uses of optional_jid and unwrap_or_default patterns
rg "optional_jid.*unwrap_or_default" --no-heading -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 974


🏁 Script executed:

# Search for how JID validation is done elsewhere in the codebase
rg "optional_jid" -t rs --no-heading -B 1 -A 1 | head -50

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Look at what happens with the jid and lid after they're parsed
cat -n src/pair.rs | sed -n '200,250p'

Repository: jlucaso1/whatsapp-rust

Length of output: 2624


🏁 Script executed:

# Search for where these variables are used in the rest of the function
cat -n src/pair.rs | sed -n '200,320p'

Repository: jlucaso1/whatsapp-rust

Length of output: 5767


🏁 Script executed:

# Look for PairSuccess struct or device initialization
rg "struct PairSuccess|PairSuccess\{" -A 5 -B 2

Repository: jlucaso1/whatsapp-rust

Length of output: 436


🏁 Script executed:

# Check if jid/lid are validated or checked anywhere
rg "jid.*is_empty|jid.*default|Jid::default" --no-heading -B 2 -A 2 | head -40

Repository: jlucaso1/whatsapp-rust

Length of output: 2257


🏁 Script executed:

# Look for the rest of the function to see PairSuccess event dispatch
cat -n src/pair.rs | sed -n '278,350p'

Repository: jlucaso1/whatsapp-rust

Length of output: 3028


🏁 Script executed:

# Check how SetId and SetLid commands are handled
rg "DeviceCommand::SetId|DeviceCommand::SetLid" -A 5 -B 2

Repository: jlucaso1/whatsapp-rust

Length of output: 1978


🏁 Script executed:

# Check if there's any validation when storing empty JIDs
rg "SetId|SetLid" --no-heading -B 3 -A 3 | grep -A 10 -B 10 "empty\|validate\|default"

Repository: jlucaso1/whatsapp-rust

Length of output: 48


Reject pair-success messages with missing or malformed jid/lid attributes instead of defaulting to empty JIDs.

Using optional_jid(...).unwrap_or_default() silently collapses both missing and unparseable jid/lid attributes into Jid::default(). This allows the pairing to succeed and persist empty account identifiers to the device store (via SetId and SetLid commands), creating an inconsistent state. The check at line 263 only prevents LID-PN mapping, not device persistence.

Since device-identity is strictly validated (lines 163–178), jid and lid should be treated with the same rigor: fail the pairing and return an error if either attribute is missing or malformed, rather than proceeding with defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 201 - 206, The current code silently replaces
missing or malformed device jid/lid with Jid::default() by using
optional_jid(...).unwrap_or_default(), which allows invalid pair-success
messages to proceed and persist empty identifiers; change the logic in the
pair-success handling (the block that calls
success_node.get_optional_child_by_tag("device") and uses
device_node.attr_parser() to parse "jid" and "lid") to validate both attributes
strictly: attempt to parse each with optional_jid (or the parser's parsing
method) and if either returns None or an Err, immediately return an error
rejecting the pair-success instead of defaulting, preventing issuing
SetId/SetLid commands with empty JIDs and matching the strict validation used
for device-identity.

Comment thread wacore/src/iq/tctoken.rs Outdated
Comment thread wacore/src/iq/usync.rs
Comment on lines 121 to 128
/// Parse LID JID from a `<lid val="..."/>` child node.
fn parse_lid_jid(user_node: &Node) -> Option<Jid> {
fn parse_lid_jid(user_node: &NodeRef<'_>) -> Option<Jid> {
user_node.get_optional_child("lid").and_then(|lid_node| {
lid_node
.attrs()
.attr_parser()
.optional_string("val")
.and_then(|val| val.parse::<Jid>().ok())
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether AttrParser distinguishes string attrs from JID attrs,
# then inspect the affected usync call sites.
rg -n -C3 'fn optional_string|fn optional_jid|fn as_str' wacore/binary/src
echo
rg -n -C2 'optional_string\("jid"\)|optional_string\("pn_jid"\)|optional_string\("val"\)' wacore/src/iq/usync.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 4657


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the attr_parser() implementation for NodeRef
echo "=== Looking for NodeRef::attr_parser() signature ==="
rg -n 'impl.*NodeRef|fn attr_parser' wacore/binary/src/node.rs -A 2

echo
echo "=== Check ValueRef methods to understand to_string_cow behavior ==="
rg -n 'fn to_string_cow|fn to_jid' wacore/binary/src/node.rs -A 5

Repository: jlucaso1/whatsapp-rust

Length of output: 1499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for tests in usync.rs that test JID parsing
echo "=== Test coverage for usync parsing ==="
rg -n '#\[test\]|fn test' wacore/src/iq/usync.rs | head -20

echo
echo "=== Check if any tests use non-string JID attrs ==="
rg -n 'Jid\(|ValueRef::Jid|NodeValue::Jid' wacore/src/iq/usync.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 812


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find AttrParserRef definition and which ValueRef methods it uses
echo "=== AttrParserRef definition ==="
rg -n 'struct AttrParserRef|impl.*AttrParserRef' wacore/binary/src/attrs.rs -A 5

echo
echo "=== Full optional_string and optional_jid for AttrParserRef ==="
rg -n -A 10 'impl.*AttrParserRef' wacore/binary/src/attrs.rs | grep -A 10 'optional_string\|optional_jid'

Repository: jlucaso1/whatsapp-rust

Length of output: 491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search more carefully for AttrParserRef methods
echo "=== AttrParserRef optional_string and optional_jid ==="
sed -n '18,250p' wacore/binary/src/attrs.rs | grep -n -A 5 'fn optional_string\|fn optional_jid'

Repository: jlucaso1/whatsapp-rust

Length of output: 1265


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if usync responses actually produce Jid-variant attributes
echo "=== Check how attrs are populated in usync parsing ==="
rg -n 'parse_response|get_optional_child|attr_parser' wacore/src/iq/usync.rs | head -30

echo
echo "=== Look at decoder to understand when Jid variants are created ==="
rg -n 'ValueRef::Jid|NodeValue::Jid' wacore/binary/src/node.rs -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 4564


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if there's any code that specifically creates Jid-variant attrs in responses
echo "=== Search for where Jid variants are created during decoding ==="
rg -n 'NodeValue::Jid' wacore/binary/src -B 3 | head -50

echo
echo "=== Check usync test fixtures to see if they ever contain JID attrs ==="
rg -n 'jid|pn_jid|lid' wacore/src/iq/usync.rs -i | grep -E 'test_|#\[test\]|"jid"|"pn_jid"' | head -30

Repository: jlucaso1/whatsapp-rust

Length of output: 4932


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look at actual test data to understand what attrs are used
echo "=== Get a test fixture from usync tests ==="
sed -n '855,886p' wacore/src/iq/usync.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 1418


Use optional_jid() for cleaner JID attribute parsing.

The current code using optional_string("jid").and_then(|s| s.parse::<Jid>()) works correctly but is inefficient: when the decoder produces a ValueRef::Jid variant (which is supported by the binary protocol), to_string_cow() allocates a formatted string, then the code re-parses it back to Jid. Using optional_jid() directly handles both string and Jid variants without the unnecessary allocation and re-parse cycle.

💡 Suggested fix
 fn parse_lid_jid(user_node: &NodeRef<'_>) -> Option<Jid> {
-    user_node.get_optional_child("lid").and_then(|lid_node| {
-        lid_node
-            .attr_parser()
-            .optional_string("val")
-            .and_then(|val| val.parse::<Jid>().ok())
-    })
+    user_node
+        .get_optional_child("lid")
+        .and_then(|lid_node| lid_node.attr_parser().optional_jid("val"))
 }
 
 fn parse_user_common_fields(user_node: &NodeRef<'_>) -> Option<ParsedUserFields> {
-    let jid = user_node
-        .attr_parser()
-        .optional_string("jid")?
-        .parse::<Jid>()
-        .ok()?;
+    let jid = user_node.attr_parser().optional_jid("jid")?;
@@
         for user_node in list.get_children_by_tag("user") {
-            let Some(jid_str) = user_node.attr_parser().optional_string("jid") else {
+            let Some(jid) = user_node.attr_parser().optional_jid("jid") else {
                 continue;
             };
-            let Ok(jid) = jid_str.parse::<Jid>() else {
-                continue;
-            };
 
             let pn_jid = user_node
                 .attr_parser()
-                .optional_string("pn_jid")
-                .and_then(|s| s.parse::<Jid>().ok());
+                .optional_jid("pn_jid");

Also applies to: 140-145, 315-326

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/usync.rs` around lines 121 - 128, The current parsing in
parse_lid_jid uses attr_parser().optional_string("val").and_then(|val|
val.parse::<Jid>().ok()) which forces allocation and re-parsing when the decoder
already provides a Jid; replace that pattern with
attr_parser().optional_jid("val") to avoid needless to_string/parse. Apply the
same change to the other occurrences mentioned (the similar parsing blocks
around the parse user/jid logic at the other sites referenced) so they call
optional_jid("...") instead of optional_string(...).and_then(|s|
s.parse::<Jid>().ok()), keeping return types as Option<Jid>.

Comment thread wacore/src/pair_code.rs
Comment thread wacore/src/pair.rs
Comment on lines +85 to +98
/// Builds acknowledgment node for a pairing request from a NodeRef.
pub fn build_ack_node_ref(request_node: &NodeRef<'_>) -> Option<Node> {
let to = request_node.get_attr("from").map(|v| v.to_string_cow())?;
let id = request_node.get_attr("id").and_then(|v| v.as_str())?;
Some(
NodeBuilder::new("iq")
.attrs([
("to", to.to_string()),
("id", id.to_string()),
("type", "result".to_string()),
])
.build(),
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Collapse the two ACK builders into one implementation.

build_ack_node_ref now mirrors build_ack_node field-for-field, so the next ACK shape change has to be fixed in two places. Please extract a shared private builder over (to, id) or remove the owned-node variant once the remaining callers are migrated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/pair.rs` around lines 85 - 98, build_ack_node_ref duplicates
build_ack_node; extract a single private builder that takes (to: &str, id: &str)
and returns the Node, then have both build_ack_node_ref and build_ack_node call
that shared helper. Implement something like a private fn (e.g., build_ack_inner
or build_ack_for) that constructs the Node via NodeBuilder using the provided
&strs, and update build_ack_node_ref to map attrs to &str and pass them through,
and update build_ack_node to forward its owned Strings to the same helper
(converting to &str as needed) so there is a single place to change the ACK
shape.

Comment thread wacore/src/prekeys.rs
Comment on lines +144 to 147
fn extract_bytes_ref(node: Option<&NodeRef<'_>>) -> Result<Vec<u8>, anyhow::Error> {
match node.and_then(|n| n.content.as_deref()) {
Some(NodeContentRef::Bytes(b)) => Ok(b.to_vec()),
_ => Err(anyhow::anyhow!("Expected bytes in node content")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Keep the prekey parser zero-copy through the fixed-size fields.

These branches still to_vec() borrowed bytes and then immediately length-check/copy them into arrays. That reintroduces heap traffic for registration, identity keys, prekeys, and signatures right after the NodeRef migration. Borrow the slice directly and copy into stack arrays only at the final boundary.

Also applies to: 158-172, 241-257, 270-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/prekeys.rs` around lines 144 - 147, The parser currently calls
to_vec() on NodeContentRef::Bytes (in extract_bytes_ref and similar branches)
which allocates; instead return a borrowed slice (e.g., Result<&[u8],
anyhow::Error> from extract_bytes_ref or add an extract_bytes_ref_slice that
returns &[u8]) and propagate that borrowed slice through the registration,
identity key, prekey, and signature parsing code paths; only at the final
boundary, when you must produce a fixed-size array, validate the slice length
and copy into the stack array (e.g., using try_into().map_err(...) and copying
into [u8; N]) so no intermediate Vec is allocated. Ensure you update call sites
that expect Vec<u8> to accept &[u8] or perform the single copy at the end in
functions handling registration, identity keys, prekeys, and signatures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2fe06ef599

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/jid.rs
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Server {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = <&str>::deserialize(deserializer)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deserialize Server from owned strings

Switching Server deserialization to let s = <&str>::deserialize(...) requires the deserializer to hand out a borrowed string, which many valid serde inputs cannot do (for example streaming/reader-based JSON and several binary formats). In those contexts, deserializing JIDs now fails even when the wire value is a correct server domain. Deserialize into String (or Cow<'de, str>) first, then map with Server::try_from to keep compatibility across serde backends.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch 2 times, most recently from cf3ac5e to 5a19d4b Compare April 12, 2026 01:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
wacore/src/stanza/business.rs (1)

187-237: 🧹 Nitpick | 🔵 Trivial

Collapse these nested option probes into let-chains.

The new remove / verified_name / subscriptions parsing branches reintroduce nested if let blocks in code that was just touched. Flattening them with let-chains will keep the parser easier to scan and aligned with the repo’s expected style.

As per coding guidelines, "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns".

Also applies to: 302-321

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/stanza/business.rs` around lines 187 - 237, The parsing branches
for remove, verified_name (and the similar subscriptions block) use nested
if-let blocks; refactor them to use let-chains so the checks are flattened and
collapsible (e.g. combine node.get_optional_child("remove") and
remove_node.attr_parser().optional_jid("jid") in a single if let chain). Update
the branches that produce BusinessNotificationType::RemoveJid / RemoveHash and
BusinessNotificationType::VerifiedNameJid / VerifiedNameHash to use single if
let chains and call VerifiedName::try_from_node(vn_node)? only after the vn_node
is present (or include it in the same chain), and do the same for the
profile/subscriptions parsing to replace nested if lets with combined let-chains
using get_optional_child, attr_parser().optional_jid / optional_string, and
VerifiedName::try_from_node where applicable.
src/handlers/ib.rs (1)

123-133: 🛠️ Refactor suggestion | 🟠 Major

Route edge-routing persistence through a DeviceCommand.

This still mutates Device directly via modify_device(...). Please push edge_routing_info through PersistenceManager::process_command(...) instead so the write follows the project's state-mutation path.

As per coding guidelines "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 123 - 133, The code currently calls
PersistenceManager::modify_device(...) to set device.edge_routing_info directly;
instead create or use a DeviceCommand variant (e.g., SetEdgeRoutingInfo or
UpdateEdgeRoutingInfo) that carries the routing bytes and send it to
PersistenceManager::process_command(...) so mutation flows through the project's
state path; locate the async task where client.runtime.spawn is used, stop
calling persistence_manager.modify_device, build the DeviceCommand with the
routing_bytes (cloned as needed), call
persistence_manager.process_command(command).await and read device state via
persistence_manager.get_device_snapshot() when reads are required.
wacore/src/usync.rs (1)

163-184: ⚠️ Potential issue | 🟠 Major

Use optional_jid() to read JID attributes on the zero-copy path.

The zero-copy decoder materializes JID-typed attributes as ValueRef::Jid directly (without string allocation). Using optional_string() returns None for Jid variants and silently skips valid PN↔LID mappings. Use optional_jid() instead, which handles both String and Jid variants.

Suggested fix
-        let user_jid_str = match user_node.attr_parser().optional_string("jid") {
-            Some(jid) => jid,
+        let user_jid = match user_node.attr_parser().optional_jid("jid") {
+            Some(jid) => jid,
             None => continue,
         };
-        let user_jid: Jid = match user_jid_str.parse() {
-            Ok(j) => j,
-            Err(_) => continue,
-        };
 
         if user_jid.server != wacore_binary::Server::Pn {
             continue;
         }
 
         if let Some(lid_node) = user_node.get_optional_child("lid") {
-            let lid_val = match lid_node.attr_parser().optional_string("val") {
-                Some(v) => v,
-                None => continue,
-            };
-            if !lid_val.is_empty()
-                && let Ok(lid_jid) = lid_val.parse::<Jid>()
-                && lid_jid.server == wacore_binary::Server::Lid
-            {
+            if let Some(lid_jid) = lid_node.attr_parser().optional_jid("val")
+                && lid_jid.server == wacore_binary::Server::Lid
+            {
                 mappings.push(UsyncLidMapping {
                     phone_number: user_jid.user.clone(),
                     lid: lid_jid.user.clone(),
                 });
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/usync.rs` around lines 163 - 184, The code currently reads JIDs
using user_node.attr_parser().optional_string("jid") and
lid_node.attr_parser().optional_string("val"), which fails on zero-copy Jid
variants; replace those calls with optional_jid() and handle the returned Jid
(or Option<Jid>) directly (e.g., parse/assign to user_jid via
user_node.attr_parser().optional_jid(), and for lid use
lid_node.attr_parser().optional_jid()) so you don't drop valid PN↔LID mappings;
update the matching logic that checks user_jid.server and lid_jid.server to work
with the Jid values returned by optional_jid().
src/client.rs (1)

2272-2317: 🧹 Nitpick | 🔵 Trivial

Avoid re-encoding ACK stanzas just to satisfy the waiter type.

handle_ack_response() marshals a borrowed NodeRef back into bytes and immediately reparses it into OwnedNodeRef. That puts an extra encode/decode + allocation cycle on every ack-waited send and gives back some of the benefit of the new zero-copy path. If the caller already has Arc<OwnedNodeRef>, thread that through here and forward it directly.

♻️ Proposed direction
-pub(crate) async fn handle_ack_response(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
+pub(crate) async fn handle_ack_response(
+    &self,
+    node: Arc<wacore_binary::OwnedNodeRef>,
+) -> bool {
+    let nr = node.get();
-    let id_opt = node.get_attr("id").map(|v| v.to_string_cow().into_owned());
+    let id_opt = nr.get_attr("id").and_then(|v| v.as_str());
     if let Some(id) = id_opt
         && let Some(waiter) = self.response_waiters.lock().await.remove(id)
     {
-        match wacore_binary::marshal::marshal_ref(node)
-            .and_then(|bytes| wacore_binary::OwnedNodeRef::new(bytes[1..].to_vec()))
-        {
-            Ok(onr) => {
-                if waiter.send(Arc::new(onr)).is_err() {
-                    warn!(target: "Client/Ack", "Failed to send ACK response to waiter for ID {id}. Receiver was likely dropped.");
-                }
-            }
-            Err(e) => {
-                warn!(target: "Client/Ack", "Failed to re-encode ACK node for waiter: {e}");
-            }
+        if waiter.send(node).is_err() {
+            warn!(target: "Client/Ack", "Failed to send ACK response to waiter for ID {id}. Receiver was likely dropped.");
         }
         return true;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 2272 - 2317, handle_ack_response currently
re-encodes the borrowed NodeRef into bytes then reconstructs an OwnedNodeRef
before sending to the waiter, causing extra encode/decode and allocations;
change the waiter plumbing so this function forwards an existing
Arc<OwnedNodeRef> when available instead of re-marshaling: update the type
stored in self.response_waiters (and the code that inserts waiters) to accept
Arc<OwnedNodeRef> or a enum that can carry either Arc<OwnedNodeRef> or a NodeRef
borrow, then in handle_ack_response, when you find the waiter directly send the
existing Arc<OwnedNodeRef> (via waiter.send(arc_onr)) and remove the
marshal::marshal_ref/OwnedNodeRef::new re-encode path and related error
handling; keep fallbacks only if no Arc is present.
wacore/src/iq/mediaconn.rs (1)

359-385: ⚠️ Potential issue | 🟠 Major

Do not turn malformed media_conn responses into partial data.

This parser currently swallows two classes of protocol errors: invalid numeric attrs are ignored because attrs.finish() is never called, and malformed <host> children are dropped by filter_map(...ok()?). That can leave callers with ttl = 0 and a truncated host list instead of a hard parse failure.

🐛 Suggested fix
         let mut attrs = media_conn_node.attr_parser();
         let auth = attrs
             .optional_string("auth")
             .ok_or_else(|| anyhow!("Missing 'auth' attribute in media_conn response"))?
             .to_string();
         let ttl = attrs.optional_u64("ttl").unwrap_or(0);
         let auth_ttl = attrs.optional_u64("auth_ttl");
         let max_buckets = attrs.optional_u64("max_buckets");
+        attrs.finish()?;

         // Parse extended host info (type, fallback) and map to MediaConnHost.
         // Sort: primary hosts first, fallback hosts second (matches WA Web's mapParsedMediaConn).
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
             .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
+            .map(MediaConnHostExtended::try_from_node_ref)
+            .collect::<Result<Vec<_>, _>>()?
+            .into_iter()
+            .map(|ext| MediaConnHost {
+                hostname: ext.hostname,
+                host_type: ext.host_type,
+                fallback_hostname: ext.fallback_hostname,
+            })
             .collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 359 - 385, In parse_response, don't
silently drop malformed fields: call attrs.finish() after reading
auth/ttl/auth_ttl/max_buckets to surface invalid numeric attrs, and change the
host parsing pipeline that currently uses
filter_map(MediaConnHostExtended::try_from_node_ref.ok()?) to a fallible parse
that returns Err if any host fails (e.g., map each host_node with
MediaConnHostExtended::try_from_node_ref and propagate errors instead of
filtering them out), then build Vec<MediaConnHost> from the successful
conversions; this ensures parse_response returns an error on malformed numeric
attributes or malformed <host> children rather than producing partial data.
wacore/src/iq/props.rs (1)

191-216: 🧹 Nitpick | 🔵 Trivial

Avoid parsing each <prop> twice.

AbPropConfig::try_from_node_ref currently runs both parsers and builds both error paths before deciding which variant it is. Dispatching once on config_code vs event_code will cut duplicate attribute scans and error construction from this response parser.

♻️ Suggested shape
     fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self, anyhow::Error> {
         if node.tag != "prop" {
             return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
         }

-        let experiment = AbProp::try_from_node_ref(node);
-        if let Ok(prop) = experiment {
-            return Ok(Self::Experiment(prop));
-        }
-
-        let sampling = SamplingProp::try_from_node_ref(node);
-        if let Ok(prop) = sampling {
-            return Ok(Self::Sampling(prop));
-        }
-
-        let experiment_err = experiment
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        let sampling_err = sampling
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        Err(anyhow::anyhow!(
-            "prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
-            experiment_err,
-            sampling_err
-        ))
+        match (
+            node.get_attr("config_code").is_some(),
+            node.get_attr("event_code").is_some(),
+        ) {
+            (true, false) => AbProp::try_from_node_ref(node).map(Self::Experiment),
+            (false, true) => SamplingProp::try_from_node_ref(node).map(Self::Sampling),
+            _ => Err(anyhow::anyhow!(
+                "prop must contain exactly one of config_code or event_code"
+            )),
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/props.rs` around lines 191 - 216, The current try_from_node_ref
implementation needlessly invokes both AbProp::try_from_node_ref and
SamplingProp::try_from_node_ref and constructs both error paths; instead, read
the distinguishing attribute(s) on the NodeRef (e.g., look for "config_code" vs
"event_code" or whatever attribute identifies an Experiment vs Sampling) and
dispatch once to the appropriate parser (call AbProp::try_from_node_ref only
when the node indicates an Experiment, SamplingProp::try_from_node_ref only when
it indicates Sampling). If the attribute is missing or unrecognized, return a
clear error; if the chosen parser fails, return that parser's error directly.
Keep references to try_from_node_ref, AbProp, SamplingProp, and NodeRef to
locate and change the code.
♻️ Duplicate comments (23)
src/test_utils.rs (1)

8-10: ⚠️ Potential issue | 🟡 Minor

Avoid the extra copy and guard the format-byte assumption.

Line 10 allocates a second buffer and can panic on empty output. Strip the leading format byte in-place and assert expectations explicitly.

♻️ Proposed fix
 pub fn node_to_owned_ref(node: &Node) -> Arc<OwnedNodeRef> {
-    let bytes = wacore_binary::marshal::marshal(node).expect("marshal should succeed");
+    let mut bytes = wacore_binary::marshal::marshal(node).expect("marshal should succeed");
     // marshal() prepends a leading format byte; OwnedNodeRef::new expects raw protocol bytes
-    Arc::new(OwnedNodeRef::new(bytes[1..].to_vec()).expect("OwnedNodeRef::new should succeed"))
+    assert!(
+        bytes.first() == Some(&0x00),
+        "marshal payload must start with format byte 0x00"
+    );
+    bytes.remove(0);
+    Arc::new(OwnedNodeRef::new(bytes).expect("OwnedNodeRef::new should succeed"))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test_utils.rs` around lines 8 - 10, The code currently creates a second
buffer by slicing bytes[1..].to_vec() and can panic on empty output; instead,
after calling wacore_binary::marshal::marshal(node) into bytes, assert bytes is
non-empty and that bytes[0] matches the expected format byte, then remove the
first byte in-place (so the existing Vec is reused) before calling
OwnedNodeRef::new; update the call sites (the bytes variable, marshal(), and
OwnedNodeRef::new usage inside Arc::new) to perform these checks and the
in-place strip to avoid the extra allocation and to guard the format-byte
assumption.
wacore/src/iq/business.rs (1)

55-59: 🧹 Nitpick | 🔵 Trivial

Avoid the extra allocation in byte-to-string conversion.

Line [58] clones bytes into a Vec<u8> before UTF-8 validation. Validate borrowed bytes first, then allocate once for the final String.

♻️ Proposed refactor
 fn node_text(node: &NodeRef<'_>) -> Option<String> {
     match node.content.as_deref() {
         Some(NodeContentRef::String(s)) => Some(s.to_string()),
-        Some(NodeContentRef::Bytes(b)) => String::from_utf8(b.to_vec()).ok(),
+        Some(NodeContentRef::Bytes(b)) => std::str::from_utf8(b).ok().map(str::to_owned),
         _ => None,
     }
 }
#!/bin/bash
# Verify whether the extra-allocation pattern still exists in the file.
rg -n 'String::from_utf8\(b\.to_vec\(\)\)\.ok\(\)' wacore/src/iq/business.rs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/business.rs` around lines 55 - 59, In node_text, avoid cloning
the bytes with b.to_vec() before UTF-8 validation; instead validate the borrowed
bytes (NodeContentRef::Bytes(b)) with std::str::from_utf8(b) and then allocate a
String only once (e.g., map the &str to String) so you replace
String::from_utf8(b.to_vec()).ok() with a from_utf8-based path that returns
Some(s.to_string()) on success; update the match arm in fn node_text
accordingly.
wacore/appstate/src/patch_decode.rs (1)

89-99: ⚠️ Potential issue | 🟠 Major

*_ref entry points still allocate and negate zero-copy parsing.

parse_patch_list_ref and parse_patch_lists_ref convert &NodeRef<'_> to owned Node via to_owned(), which defeats the zero-copy path introduced by this PR.

♻️ Suggested direction
 pub fn parse_patch_list_ref(node: &NodeRef<'_>) -> Result<PatchList> {
-    parse_patch_list(&node.to_owned())
+    let collection = node
+        .get_optional_child_by_tag(&["sync", "collection"])
+        .ok_or_else(|| anyhow!("missing sync/collection"))?;
+    parse_single_collection_ref(&collection)
 }

 pub fn parse_patch_lists_ref(node: &NodeRef<'_>) -> Result<Vec<PatchList>> {
-    parse_patch_lists(&node.to_owned())
+    let sync_node = if node.tag == "sync" {
+        node
+    } else {
+        node.get_optional_child("sync")
+            .ok_or_else(|| anyhow!("missing sync node in response"))?
+    };
+    parse_collections_ref(sync_node)
 }

Then mirror parse_single_collection/collection iteration on borrowed NodeRef helpers to remove full-tree cloning from this path.

#!/bin/bash
set -euo pipefail

# Verify current zero-copy wrappers still clone.
rg -n "parse_patch_list_ref|parse_patch_lists_ref|to_owned\\(" wacore/appstate/src/patch_decode.rs

# Verify whether borrowed helper(s) exist yet.
rg -n "parse_single_collection_ref|parse_collections_ref|parse_patch_list_ref|parse_patch_lists_ref" wacore/appstate/src/patch_decode.rs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/appstate/src/patch_decode.rs` around lines 89 - 99,
parse_patch_list_ref and parse_patch_lists_ref currently call to_owned() and
thus clone the whole Node, defeating the zero-copy intent; instead add or reuse
borrowed helpers that operate on &NodeRef to parse in-place (mirror the existing
parse_single_collection/collection-iteration logic but for borrowed NodeRef),
then have parse_patch_list_ref call that borrowed parse_single_collection_ref
(or implement parse_collections_ref) and parse_patch_lists_ref iterate child
<collection> elements via a borrowed helper without calling to_owned();
reference parse_patch_list, parse_patch_lists, parse_single_collection and
implement corresponding parse_patch_list_ref/parse_patch_lists_ref helpers that
walk NodeRef children zero-copy.
wacore/src/pair_code.rs (1)

32-34: ⚠️ Potential issue | 🟡 Minor

Pair-code IQ builders still use SERVER_JID instead of Server::Pn.

This module remains on string-based server targeting while the rest of the migration moved to the typed Server enum.

♻️ Proposed fix
-use wacore_binary::SERVER_JID;
+use wacore_binary::Server;
@@
-                ("to", SERVER_JID.to_string()),
+                ("to", Server::Pn.as_str().to_string()),
@@
-                ("to", SERVER_JID.to_string()),
+                ("to", Server::Pn.as_str().to_string()),
#!/bin/bash
set -euo pipefail

# Check remaining legacy server constant usage in this file and related modules.
rg -n "\\bSERVER_JID\\b|\\bServer::Pn\\b" wacore/src/pair_code.rs
rg -n "\\bSERVER_JID\\b" wacore/src/iq

Also applies to: 336-337, 383-384

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/pair_code.rs` around lines 32 - 34, The pair_code module still
imports and uses the string constant SERVER_JID; replace all uses with the typed
Server enum variant Server::Pn and update imports accordingly (remove
wacore_binary::SERVER_JID and add use wacore_binary::Server). In call
sites/builders (e.g., any functions that pass SERVER_JID into
NodeBuilder/Node/NodeRef/NodeContentRef), convert to the proper representation
expected by those APIs (e.g., Server::Pn or Server::Pn.to_jid() /
Server::Pn.as_str() as required by NodeBuilder) and run the ripgrep suggested
checks to update the other occurrences in the iq modules as well. Ensure
compilation by adjusting types where SERVER_JID string was assumed.
wacore/src/iq/tctoken.rs (1)

281-287: 🧹 Nitpick | 🔵 Trivial

Avoid formatting the decoded JID only to parse it again.

get_attr("jid") has already decoded this attribute, so to_string_cow().into_owned().parse() adds an avoidable allocation and a second parse in the response path.

Minimal diff
-            let jid_str = token_node
-                .get_attr("jid")
-                .map(|v| v.to_string_cow().into_owned())
-                .ok_or_else(|| anyhow::anyhow!("missing required attribute jid"))?;
-            let jid: Jid = jid_str
-                .parse()
-                .map_err(|e| anyhow::anyhow!("invalid jid '{}': {}", jid_str, e))?;
+            let jid = token_node
+                .get_attr("jid")
+                .and_then(|v| v.to_jid())
+                .ok_or_else(|| anyhow::anyhow!("missing or invalid required attribute jid"))?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/tctoken.rs` around lines 281 - 287, The code currently forces
an owned string and reparses the JID; instead use the already-decoded attribute
value without an extra allocation or reformat/parse. Replace the jid_str
construction and subsequent .parse() call so you take the attribute's borrowed
string (from token_node.get_attr("jid") e.g. v.as_ref() or similar) and pass
that borrowed &str directly into Jid parsing/constructor (or use a conversion
that accepts &str) and keep the same error handling (ok_or_else for missing
attribute and map_err for invalid JID). Reference: token_node.get_attr("jid"),
jid_str, and Jid.
src/handlers/ib.rs (1)

113-116: 🛠️ Refactor suggestion | 🟠 Major

Collapse the routing_info extraction into a let-chain.

This branch still uses nested if let blocks. Please fold them into a single let-chain so it matches the repository pattern and keeps the fallback logging branches collapsible.

Suggested change
-                if let Some(routing_info_node) = child.get_optional_child("routing_info") {
-                    if let Some(NodeContentRef::Bytes(routing_bytes)) =
-                        routing_info_node.content.as_deref()
-                    {
+                let routing_info_node = child.get_optional_child("routing_info");
+                if let Some(routing_info_node) = routing_info_node.as_ref()
+                    && let Some(NodeContentRef::Bytes(routing_bytes)) =
+                        routing_info_node.content.as_deref()
+                {
                         if !routing_bytes.is_empty() {
                             debug!(
                                 "Received edge routing info ({} bytes), storing for reconnection",
@@
                         } else {
                             debug!("Received empty edge routing info, ignoring");
                         }
-                    } else {
-                        debug!("Edge routing info node has no bytes content");
-                    }
-                } else {
-                    debug!("Edge routing stanza has no routing_info child");
-                }
+                } else if routing_info_node.is_none() {
+                    debug!("Edge routing stanza has no routing_info child");
+                } else {
+                    debug!("Edge routing info node has no bytes content");
+                }

As per coding guidelines "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 113 - 116, Collapse the nested `if let` into
a let-chain: replace the two-level check that first binds `routing_info_node`
from `child.get_optional_child("routing_info")` and then matches
`routing_info_node.content.as_deref()` to
`Some(NodeContentRef::Bytes(routing_bytes))` with a single `if let
Some(routing_info_node) = child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref()` pattern so the branch is one collapsible
`if` block; keep the existing fallback logging branches unchanged and ensure
`routing_bytes` is the bound identifier used inside the body.
src/client/device_registry.rs (1)

301-313: ⚠️ Potential issue | 🟠 Major

Preserve the real (user, server) pairs when purging sessions.

This still cross-products lookup.all_keys() with both Server::Lid and Server::Pn, so a mapped user gets four session deletions, including invalid mixed pairs. That can purge an unrelated session if a numeric LID collides with a real PN, and it also means the Unknown path still can't honor the caller's actual server. Match on UserLookupKeys and only delete the concrete pairs you resolved.

Suggested direction
match lookup {
    UserLookupKeys::LidWithPn { lid, pn } | UserLookupKeys::PnWithLid { lid, pn } => {
        purge(&lid, wacore_binary::Server::Lid, device_ids).await;
        purge(&pn, wacore_binary::Server::Pn, device_ids).await;
    }
    UserLookupKeys::Unknown { user } => {
        purge(&user, caller_server, device_ids).await;
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 301 - 313, The current
delete_sessions_for_devices cross-products resolve_lookup_keys() with both
Server::Lid and Server::Pn, which can purge incorrect pairs; change
delete_sessions_for_devices(&self, user: &str, device_ids: &[u16]) to accept the
caller's server (e.g. caller_server: wacore_binary::Server), call
resolve_lookup_keys(user).await and match on the returned UserLookupKeys
variants (UserLookupKeys::LidWithPn { lid, pn }, UserLookupKeys::PnWithLid {
lid, pn }, UserLookupKeys::Unknown { user }) and only construct Jid::new(...)
with the concrete (key, server) pairs for each variant (use Server::Lid for lid
and Server::Pn for pn; for Unknown use caller_server), then call
signal_cache.delete_session with the protocol address for each device_id — do
not iterate over both servers for every key.
wacore/src/prekeys.rs (1)

144-147: 🛠️ Refactor suggestion | 🟠 Major

Avoid reintroducing heap copies in the zero-copy prekey parser.

These paths still call to_vec() on borrowed NodeContentRef::Bytes and then immediately length-check/copy into fixed-size arrays. That gives back a chunk of the allocation savings from the NodeRef migration on the prekey parse path. Keep these helpers on &[u8], validate lengths on the borrowed slice, and only copy once at the final [u8; N] boundary.

Also applies to: 158-175, 241-257, 270-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/prekeys.rs` around lines 144 - 147, The helper extract_bytes_ref
currently calls to_vec() on NodeContentRef::Bytes which reintroduces heap
allocation; change it to return Result<&[u8], anyhow::Error> (keep the borrowed
slice) and perform length validation on that &[u8] where called, only copying
into fixed-size arrays at the final conversion point (e.g., when constructing
[u8; N] in the prekey parsing functions). Apply the same change to the other
helper usages in the file (the blocks around the commented ranges 158-175,
241-257, 270-286): avoid to_vec(), validate slice length via slice.len() or
try_into() on the borrowed &[u8], and perform a single copy into the fixed-size
array only when the length is correct.
src/pair.rs (2)

54-56: 🧹 Nitpick | 🔵 Trivial

Keep QR ref decoding borrowed until UTF-8 validation succeeds.

String::from_utf8(bytes.to_vec()) allocates before you know the payload is valid UTF-8. Borrowed validation keeps the zero-copy path intact and only allocates for the final owned string.

♻️ Minimal diff
-                        if let Some(NodeContentRef::Bytes(bytes)) = grandchild.content.as_deref()
-                            && let Ok(r) = String::from_utf8(bytes.to_vec())
+                        if let Some(NodeContentRef::Bytes(bytes)) = grandchild.content.as_deref()
+                            && let Ok(r) = std::str::from_utf8(bytes.as_ref())
                         {
-                            codes.push(PairUtils::make_qr_data(&device_state, r));
+                            codes.push(PairUtils::make_qr_data(&device_state, r.to_owned()));
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 54 - 56, The code prematurely allocates by calling
String::from_utf8(bytes.to_vec()) while still holding a borrowed reference from
grandchild.content.as_deref(); instead validate the borrowed bytes first using
std::str::from_utf8(bytes) (or core::str::from_utf8) to keep the zero-copy path,
and only after the from_utf8() succeeds convert to an owned String (e.g.,
.to_owned() or String::from) for further use; locate the NodeContentRef::Bytes
match and replace the String::from_utf8(bytes.to_vec()) check with a borrowed
UTF-8 validation using from_utf8, then map the validated &str to an owned
String.

201-208: ⚠️ Potential issue | 🟠 Major

Reject pair-success stanzas with missing or malformed jid/lid.

unwrap_or_default() collapses bad data into Jid::default(), and the success path later persists those values through SetId/SetLid. That can leave the device store in an inconsistent paired state after a malformed stanza.

🛠️ Minimal diff
-    let (jid, lid) = if let Some(device_node) = success_node.get_optional_child_by_tag(&["device"])
-    {
-        let mut parser = device_node.attr_parser();
-        let parsed_jid = parser.optional_jid("jid").unwrap_or_default();
-        let parsed_lid = parser.optional_jid("lid").unwrap_or_default();
-        (parsed_jid, parsed_lid)
-    } else {
-        (Jid::default(), Jid::default())
-    };
+    let Some(device_node) = success_node.get_optional_child_by_tag(&["device"]) else {
+        error!("pair-success is missing <device>");
+        return;
+    };
+    let mut parser = device_node.attr_parser();
+    let (Some(jid), Some(lid)) = (parser.optional_jid("jid"), parser.optional_jid("lid")) else {
+        error!("pair-success is missing jid/lid attributes");
+        return;
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 201 - 208, The code currently uses
unwrap_or_default() for parsed_jid/parsed_lid which masks missing or malformed
values; instead, change the logic in the success_node/device_node branch to
explicitly validate both parser.optional_jid("jid") and
parser.optional_jid("lid") and reject the pair-success stanza if either is
missing or fails to parse (do not substitute Jid::default()). Locate the parsing
in success_node -> device_node where parsed_jid/parsed_lid are extracted and
replace the unwrap_or_default usage with an early error/reject path (e.g.,
return Err or call the existing stanza-reject handler) so invalid jid/lid values
are not persisted later via SetId/SetLid.
wacore/src/iq/usync.rs (1)

121-128: 🧹 Nitpick | 🔵 Trivial

Parse JID-typed attrs with optional_jid() directly.

These paths still stringify JID attrs and parse them back into Jid. On the new NodeRef path that drops the already-decoded typed value and adds avoidable allocation/reparse work in hot usync parsing.

♻️ Representative diff
 fn parse_lid_jid(user_node: &NodeRef<'_>) -> Option<Jid> {
-    user_node.get_optional_child("lid").and_then(|lid_node| {
-        lid_node
-            .attr_parser()
-            .optional_string("val")
-            .and_then(|val| val.parse::<Jid>().ok())
-    })
+    user_node
+        .get_optional_child("lid")
+        .and_then(|lid_node| lid_node.attr_parser().optional_jid("val"))
 }

 fn parse_user_common_fields(user_node: &NodeRef<'_>) -> Option<ParsedUserFields> {
-    let jid = user_node
-        .attr_parser()
-        .optional_string("jid")?
-        .parse::<Jid>()
-        .ok()?;
+    let jid = user_node.attr_parser().optional_jid("jid")?;

Also applies to: 140-145, 315-326, 558-564

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/usync.rs` around lines 121 - 128, Replace the current pattern
that reads a JID attr as a string then reparses it with the typed accessor: in
parse_lid_jid (and the other similar parsing sites) swap
user_node.get_optional_child("lid").and_then(|lid_node|
lid_node.attr_parser().optional_string("val").and_then(|val|
val.parse::<Jid>().ok())) for
user_node.get_optional_child("lid").and_then(|lid_node|
lid_node.attr_parser().optional_jid("val")), preserving the Option<Jid> return
type; apply the same change to the other occurrences that currently stringify
then parse JID attributes.
src/pair_code.rs (1)

238-245: 🧹 Nitpick | 🔵 Trivial

Parse the wrapped ephemeral into a fixed array instead of a Vec.

This branch already requires exactly 80 bytes, so keeping it as a Vec<u8> adds an avoidable allocation right before decrypt_primary_ephemeral_pub.

♻️ Minimal diff
-    let primary_wrapped_ephemeral = match reg_node
+    let primary_wrapped_ephemeral: [u8; 80] = match reg_node
         .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
         .and_then(|n| match n.content.as_deref() {
-            Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
+            Some(NodeContentRef::Bytes(b)) => b.as_ref().try_into().ok(),
             _ => None,
         }) {
         Some(b) => b,
         None => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair_code.rs` around lines 238 - 245, The code currently extracts an
80-byte NodeContentRef::Bytes into a Vec<u8> (primary_wrapped_ephemeral) even
though the length is enforced; change the extraction to produce a fixed array
[u8; 80] instead. In the match inside reg_node.get_optional_child_by_tag(...)
when you see Some(NodeContentRef::Bytes(b)) if b.len() == 80, convert b into a
[u8;80] (e.g. use b.try_into().ok() or allocate let mut arr = [0u8;80];
arr.copy_from_slice(&b)) and return that array (or a reference) instead of
Vec<u8>, then pass the [u8;80] (or &arr) into decrypt_primary_ephemeral_pub so
the extra heap allocation is eliminated; update any bindings/types that expect
Vec<u8> accordingly.
wacore/binary/src/jid.rs (1)

508-512: ⚠️ Potential issue | 🟠 Major

actual_agent() still leaks agent state on other agent-less servers.

Only zeroing Server::Pn is inconsistent with the rest of this file, which also treats Server::Lid, Server::Hosted, and Server::HostedLid as agent-less encodings. Manually constructed JIDs can therefore retain agent bytes that disappear when formatted and reparsed.

Minimal fix
     pub fn actual_agent(&self) -> u8 {
         match self.server {
-            Server::Pn => 0,
+            Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid => 0,
             _ => self.agent,
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 508 - 512, The actual_agent() method
currently only zeros the agent for Server::Pn but should treat all agent-less
server variants consistently; update actual_agent() (in the JID implementation)
to return 0 when self.server is any of Server::Pn, Server::Lid, Server::Hosted,
or Server::HostedLid and otherwise return self.agent so manually constructed
JIDs don't leak leftover agent bytes.
wacore/src/iq/blocklist.rs (1)

116-131: 🧹 Nitpick | 🔵 Trivial

Reuse BlocklistResponse::try_from_node_ref here.

This reimplements the same <list>/direct-<item> traversal and warning path that BlocklistResponse already owns, so future schema changes now need two fixes.

Minimal cleanup
     fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
-        // BlocklistResponse checks for a <list> child or direct <item> children
-        let entries = if let Some(list) = response.get_optional_child("list") {
-            list.get_children_by_tag("item")
-        } else {
-            response.get_children_by_tag("item")
-        }
-        .filter_map(|item| match BlocklistEntry::try_from_node_ref(item) {
-            Ok(entry) => Some(entry),
-            Err(e) => {
-                warn!(target: "blocklist", "Failed to parse blocklist entry: {e}");
-                None
-            }
-        })
-        .collect();
-        Ok(entries)
+        Ok(BlocklistResponse::try_from_node_ref(response)?.entries)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/blocklist.rs` around lines 116 - 131, The parse_response
implementation duplicates BlocklistResponse's traversal and warning logic;
replace the manual <list>/<item> parsing in parse_response with a direct call to
BlocklistResponse::try_from_node_ref(response) (or the appropriate
BlocklistResponse conversion helper) and return its result, removing the
duplicated filter_map/warn logic so future schema changes remain centralized in
BlocklistResponse::try_from_node_ref.
wacore/src/iq/chatstate.rs (1)

140-153: ⚠️ Potential issue | 🟠 Major

Malformed from/participant JIDs are still treated as missing.

optional_jid() returns None on parse failure, so a bad from still becomes MissingFrom, and a bad participant downgrades a group chatstate into a user chatstate instead of failing with InvalidJid. This parser needs to distinguish malformed from absent.

#!/bin/bash
set -euo pipefail

printf '=== chatstate parser ===\n'
rg -n 'pub fn parse\(node: &NodeRef' wacore/src/iq/chatstate.rs -A25 -B5

printf '\n=== optional_jid behavior ===\n'
rg -n 'fn optional_jid|errors' wacore/binary/src -A8 -B4

Expected result: optional_jid records parse failures internally and returns None, while the chatstate parser never checks that error state before treating the attribute as absent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/chatstate.rs` around lines 140 - 153, The parser currently
treats malformed JIDs as absent because optional_jid("from") and
optional_jid("participant") return None on parse failure; update the pub fn
parse(...) in chatstate.rs to distinguish absent vs malformed by checking the
attribute parser's parse-error state after each optional_jid call and returning
ChatstateParseError::InvalidJid when a parse failure occurred (instead of
mapping to MissingFrom or silently falling back to a user chatstate);
specifically, after calling attrs.optional_jid("from") handle None by querying
the attrs error state for the "from" attribute and return InvalidJid (or
SelfEcho if the existing logic for "to" applies), and likewise when handling
attrs.optional_jid("participant") make malformed participant JIDs produce
ChatstateParseError::InvalidJid rather than treating the node as a non-group
chatstate (so construct ChatstateSource::Group only when participant parsed
successfully).
src/handlers/notification.rs (1)

557-560: ⚠️ Potential issue | 🟠 Major

Reject overflowing key-index values instead of truncating them.

v as u32 silently wraps large wire values. That can corrupt the stored key-index for a device and send later key-management logic down the wrong path. Use a fallible conversion and drop invalid values instead of narrowing them unchecked.

Minimal fix
-            let key_index = n.attr_parser().optional_u64("key-index").map(|v| v as u32);
+            let key_index = n
+                .attr_parser()
+                .optional_u64("key-index")
+                .and_then(|v| u32::try_from(v).ok());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 557 - 560, The closure in
filter_map currently truncates large key-index values with `v as u32`; replace
that with a fallible conversion using `u32::try_from` and, if the conversion
fails, drop the entire item (return None from the closure) instead of silently
truncating. Concretely, change the `key_index` handling in the closure that
builds AccountSyncDevice so that `optional_u64("key-index")` is matched: if
Some(v) then attempt `u32::try_from(v)` and return None on Err, otherwise use
the Ok(u32) value (or keep None if the attribute is absent); keep the rest of
the closure building `AccountSyncDevice { jid, key_index }`.
src/client.rs (1)

3640-3653: 🧹 Nitpick | 🔵 Trivial

Preserve typed attrs when building ACKs.

These to_string_cow() calls force JID-backed attrs back through string formatting before reconstructing NodeValue, so the ACK path still pays avoidable formatting/allocation costs and loses the typed encoding introduced by this PR. Build NodeValue directly from ValueRef instead.

♻️ Proposed fix
 fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>) -> Option<Node> {
-    let id = NodeValue::from(node.get_attr("id")?.to_string_cow().as_ref());
-    let from = NodeValue::from(node.get_attr("from")?.to_string_cow().as_ref());
-    let participant = node
-        .get_attr("participant")
-        .map(|v| NodeValue::from(v.to_string_cow().as_ref()));
+    let to_node_value = |value: &wacore_binary::ValueRef<'_>| match value {
+        wacore_binary::ValueRef::String(s) => NodeValue::from(s.as_ref()),
+        wacore_binary::ValueRef::Jid(j) => NodeValue::Jid(j.to_owned()),
+    };
+
+    let id = to_node_value(node.get_attr("id")?);
+    let from = to_node_value(node.get_attr("from")?);
+    let participant = node.get_attr("participant").map(to_node_value);
@@
     let typ = if tag != "message" && !is_encrypt_identity_notification(node) {
-        node.get_attr("type")
-            .map(|v| NodeValue::from(v.to_string_cow().as_ref()))
+        node.get_attr("type").map(to_node_value)
     } else {
         None
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3640 - 3653, The build_ack_node function is
rebuilding NodeValue from strings by calling to_string_cow() on attributes
(losing typed JID encoding and forcing allocations); update the construction to
build NodeValue directly from the ValueRef returned by node.get_attr(...) (e.g.,
pass the ValueRef into NodeValue constructors instead of calling
to_string_cow().as_ref()), so id, from, participant, and any other attr
NodeValue creations preserve typed encodings and avoid unnecessary
formatting/allocation.
wacore/src/iq/node.rs (1)

21-25: 🧹 Nitpick | 🔵 Trivial

required_attr still eagerly allocates on the zero-copy path.

This helper is now the choke point for required string attrs, so to_string() gives back one allocation per read. Return Cow<'_, str> here, like optional_attr, and let callers opt into ownership only when they actually need it.

♻️ Minimal refactor
 pub(crate) fn required_attr(node: &NodeRef<'_>, key: &str) -> Result<String, anyhow::Error> {
-    node.get_attr(key)
-        .map(|v| v.to_string())
-        .ok_or_else(|| anyhow!("missing required attribute {key}"))
+    node.attr_parser()
+        .optional_string(key)
+        .map(|v| v.into_owned())
+        .ok_or_else(|| anyhow!("missing required attribute {key}"))
 }
-pub(crate) fn required_attr(node: &NodeRef<'_>, key: &str) -> Result<String, anyhow::Error> {
+pub(crate) fn required_attr<'a>(
+    node: &'a NodeRef<'_>,
+    key: &str,
+) -> Result<Cow<'a, str>, anyhow::Error> {
     node.attr_parser()
         .optional_string(key)
-        .map(|v| v.into_owned())
         .ok_or_else(|| anyhow!("missing required attribute {key}"))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/node.rs` around lines 21 - 25, required_attr currently forces
an allocation by calling to_string() on NodeRef::get_attr results; change its
signature to return Result<Cow<'_, str>, anyhow::Error> (matching
optional_attr), map the Option<&str> to Cow::from (or .map(Cow::from)) instead
of to_string(), and keep the same missing-attribute error via ok_or_else(||
anyhow!("missing required attribute {key}")); update callers that need owned
Strings to call .into_owned() on the returned Cow.
wacore/src/appstate_sync.rs (1)

98-156: 🛠️ Refactor suggestion | 🟠 Major

The single-patch owned/ref paths still duplicate hydration logic.

decode_patch_list_ref and decode_patch_list still repeat the same snapshot/external-mutations download-and-replace flow. Multi-patch now has process_patch_lists, so leaving the single-patch path duplicated is still a drift risk for logging and future protocol changes.

Also applies to: 158-216

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/appstate_sync.rs` around lines 98 - 156, decode_patch_list_ref
duplicates the snapshot+external-mutations download-and-replace logic that also
exists in decode_patch_list; extract that shared hydration flow into a single
helper (e.g. hydrate_external_references or similar) that takes a PatchList/vec
of patches and the download Fn and performs the snapshot and external_mutations
replacement and logging, then call that helper from both decode_patch_list_ref
and decode_patch_list (or route the single-patch path through the existing
process_patch_lists flow) and keep calls to
process_patch_list/process_patch_lists unchanged so logging and behavior remain
consistent across single- and multi-patch paths.
src/message.rs (1)

366-383: 🛠️ Refactor suggestion | 🟠 Major

Custom enc handlers still punch a hole in the zero-copy path.

(*enc_node).to_owned() re-materializes the full <enc> subtree before dispatch, so any extension handler pays the old allocation cost again. Since this PR is already changing handler-facing APIs elsewhere, I’d still move the custom handler API to &NodeRef/&OwnedNodeRef and clone only in handlers that truly need ownership.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 366 - 383, The custom enc handler dispatch
currently forces re-materialization via (*enc_node).to_owned(); update the
custom handler API and call-site to take a reference to the node reference
(e.g., &NodeRef or &OwnedNodeRef) instead of consuming/owning the full subtree:
change the handler signature used in custom_enc_handlers (and any types
implementing handle) to accept &NodeRef/&OwnedNodeRef, and at this dispatch site
(where handler_clone.handle is invoked) pass a reference to enc_node (or
Arc/Owned wrapper) rather than calling to_owned; only clone or call to_owned
inside handler implementations that truly require ownership. Ensure identifiers
touched include custom_enc_handlers, handler.handle, enc_node,
enc_node_owned/removal, and handler_clone so callers and implementations are
updated consistently.
wacore/src/iq/groups.rs (1)

379-382: ⚠️ Potential issue | 🟠 Major

Don't coerce unknown participant types to Member.

If the server sends an unexpected type, this silently downgrades that participant and can erase admin/superadmin state in parsed group metadata. Keep None => Member, but return an error for unknown strings.

🐛 Suggested fix
-        let participant_type = attrs
-            .optional_string("type")
-            .and_then(|s| ParticipantType::try_from(s.as_ref()).ok())
-            .unwrap_or(ParticipantType::Member);
+        let participant_type = match attrs.optional_string("type") {
+            Some(s) => ParticipantType::try_from(s.as_ref())?,
+            None => ParticipantType::Member,
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/groups.rs` around lines 379 - 382, The current code silently
maps any unknown participant type string to ParticipantType::Member; change the
logic so that attrs.optional_string("type") == None still maps to
ParticipantType::Member, but when optional_string returns Some(s) attempt
ParticipantType::try_from(s.as_ref()) and if that returns Err propagate a parse
error (do not unwrap_or Member). Replace the existing chained call that sets
participant_type with code that matches on attrs.optional_string("type") and
returns an Err when try_from fails (referencing participant_type,
attrs.optional_string, and ParticipantType::try_from/ParticipantType::Member).
src/handlers/receipt.rs (1)

28-28: 🧹 Nitpick | 🔵 Trivial

Move node into handle_receipt instead of cloning it.

node is not used after this call, so the extra Arc::clone(&node) just adds refcount churn on a very hot path.

♻️ Minimal diff
-        client.handle_receipt(Arc::clone(&node)).await;
+        client.handle_receipt(node).await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/receipt.rs` at line 28, The call currently clones the Arc for
node (Arc::clone(&node)) when invoking client.handle_receipt(...), causing
unnecessary refcount work; since node is not used afterward, move (consume) the
Arc into the call instead of cloning it by passing node directly to
handle_receipt (i.e., change the invocation that uses Arc::clone(&node) to pass
node), and ensure the signature of handle_receipt (client.handle_receipt)
accepts Arc<Node> by value so ownership transfer is correct.
wacore/src/pair.rs (1)

85-98: 🧹 Nitpick | 🔵 Trivial

Collapse the ACK builders into one helper.

build_ack_node_ref now mirrors build_ack_node field-for-field, so the ACK shape has to be kept in sync in two places.

♻️ Suggested direction
+    fn build_ack_node_inner(to: &str, id: &str) -> Node {
+        NodeBuilder::new("iq")
+            .attrs([
+                ("to", to.to_string()),
+                ("id", id.to_string()),
+                ("type", "result".to_string()),
+            ])
+            .build()
+    }
+
     pub fn build_ack_node(request_node: &Node) -> Option<Node> {
-        if let (Some(to), Some(id)) = (request_node.attrs.get("from"), request_node.attrs.get("id"))
-        {
-            Some(
-                NodeBuilder::new("iq")
-                    .attrs([
-                        ("to", to.to_string()),
-                        ("id", id.to_string()),
-                        ("type", "result".to_string()),
-                    ])
-                    .build(),
-            )
-        } else {
-            None
-        }
+        let (Some(to), Some(id)) = (request_node.attrs.get("from"), request_node.attrs.get("id")) else {
+            return None;
+        };
+        Some(Self::build_ack_node_inner(to, id))
     }

     pub fn build_ack_node_ref(request_node: &NodeRef<'_>) -> Option<Node> {
         let to = request_node.get_attr("from").map(|v| v.to_string_cow())?;
         let id = request_node.get_attr("id").and_then(|v| v.as_str())?;
-        Some(
-            NodeBuilder::new("iq")
-                .attrs([
-                    ("to", to.to_string()),
-                    ("id", id.to_string()),
-                    ("type", "result".to_string()),
-                ])
-                .build(),
-        )
+        Some(Self::build_ack_node_inner(to.as_ref(), id))
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/pair.rs` around lines 85 - 98, build_ack_node_ref duplicates
build_ack_node; collapse them by extracting a single helper (e.g., build_ack)
that constructs the common "iq" result Node from generic inputs (like to and id)
or from a reference type, then have build_ack_node_ref call that helper instead
of duplicating logic; update both build_ack_node and build_ack_node_ref to use
the new helper and ensure callers still receive Option<Node> or Node as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c33d2a43-8b56-49bb-9d81-19910e89ff5e

📥 Commits

Reviewing files that changed from the base of the PR and between ff7c639 and 2fe06ef.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (116)
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs

Comment thread src/handlers/message.rs Outdated
Comment thread src/retry.rs Outdated
Comment thread src/retry.rs
Comment on lines 132 to 143
// Domain type mapping — must mirror encoder's server_to_domain_type().
// WA Web: 0=WHATSAPP, 1=LID, even+bit7=HOSTED, 129=HOSTED_LID, else throw.
// server_to_domain_type encodes Pn/unknown as the agent value directly,
// so unmapped agents round-trip as Pn with the original agent preserved.
let server = match agent {
0 => Cow::Borrowed(crate::jid::DEFAULT_USER_SERVER),
1 => Cow::Borrowed(crate::jid::HIDDEN_USER_SERVER),
128 => Cow::Borrowed(crate::jid::HOSTED_SERVER),
129 => Cow::Borrowed(crate::jid::HOSTED_LID_SERVER),
n if (n & 128) != 0 && (n & 1) == 0 => {
// WA Web treats any even number with bit 7 set as HOSTED
Cow::Borrowed(crate::jid::HOSTED_SERVER)
}
_ => {
return Err(BinaryError::AttrParse(format!(
"AD_JID invalid domain type: {agent}"
)));
}
0 => crate::jid::Server::Pn,
1 => crate::jid::Server::Lid,
128 => crate::jid::Server::Hosted,
129 => crate::jid::Server::HostedLid,
n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
_ => crate::jid::Server::Pn,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject unsupported AD_JID domain bytes instead of coercing them to PN.

The fallback arm now turns any unknown domain-type byte into Server::Pn, which means malformed wire data gets decoded as a valid-looking WhatsApp JID instead of failing fast. That masks protocol bugs and can misroute identity handling.

Suggested fix
         let server = match agent {
             0 => crate::jid::Server::Pn,
             1 => crate::jid::Server::Lid,
             128 => crate::jid::Server::Hosted,
             129 => crate::jid::Server::HostedLid,
             n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
-            _ => crate::jid::Server::Pn,
+            _ => return Err(BinaryError::InvalidNode),
         };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Domain type mapping — must mirror encoder's server_to_domain_type().
// WA Web: 0=WHATSAPP, 1=LID, even+bit7=HOSTED, 129=HOSTED_LID, else throw.
// server_to_domain_type encodes Pn/unknown as the agent value directly,
// so unmapped agents round-trip as Pn with the original agent preserved.
let server = match agent {
0 => Cow::Borrowed(crate::jid::DEFAULT_USER_SERVER),
1 => Cow::Borrowed(crate::jid::HIDDEN_USER_SERVER),
128 => Cow::Borrowed(crate::jid::HOSTED_SERVER),
129 => Cow::Borrowed(crate::jid::HOSTED_LID_SERVER),
n if (n & 128) != 0 && (n & 1) == 0 => {
// WA Web treats any even number with bit 7 set as HOSTED
Cow::Borrowed(crate::jid::HOSTED_SERVER)
}
_ => {
return Err(BinaryError::AttrParse(format!(
"AD_JID invalid domain type: {agent}"
)));
}
0 => crate::jid::Server::Pn,
1 => crate::jid::Server::Lid,
128 => crate::jid::Server::Hosted,
129 => crate::jid::Server::HostedLid,
n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
_ => crate::jid::Server::Pn,
};
// Domain type mapping — must mirror encoder's server_to_domain_type().
// WA Web: 0=WHATSAPP, 1=LID, even+bit7=HOSTED, 129=HOSTED_LID, else throw.
// server_to_domain_type encodes Pn/unknown as the agent value directly,
// so unmapped agents round-trip as Pn with the original agent preserved.
let server = match agent {
0 => crate::jid::Server::Pn,
1 => crate::jid::Server::Lid,
128 => crate::jid::Server::Hosted,
129 => crate::jid::Server::HostedLid,
n if (n & 128) != 0 && (n & 1) == 0 => crate::jid::Server::Hosted,
_ => return Err(BinaryError::InvalidNode),
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 132 - 143, The match on the
incoming agent byte currently coerces any unknown domain byte to
crate::jid::Server::Pn (the server variable), which hides malformed wire data;
instead change the fallback arm of the match (the "_" case in the agent ->
server mapping in decoder.rs) to return a decoding error (or propagate an Err)
rather than constructing Server::Pn. Update the surrounding function that
performs this mapping to return a Result and emit a clear error (e.g.,
InvalidServerByte(agent) or DecodeError::InvalidDomain(agent)) when the byte is
not one of the handled values (0,1,128,129 or even+bit7==hosted), so unknown
domain bytes fail fast instead of being coerced.

Comment thread wacore/derive/src/lib.rs
Comment on lines 185 to 194
(AttrType::Jid, false, _) => {
quote! {
#field_ident: node.attrs().optional_jid(#attr_name)
#field_ident: node.attr_parser().optional_jid(#attr_name)
.ok_or_else(|| ::anyhow::anyhow!("missing required attribute '{}'", #attr_name))?
}
}
(AttrType::Jid, true, _) => {
quote! {
#field_ident: node.attrs().optional_jid(#attr_name)
#field_ident: node.attr_parser().optional_jid(#attr_name)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== optional_jid implementation ===\n'
rg -n "fn optional_jid|errors" wacore/binary/src -A8 -B4

printf '\n=== JID parsing branches in derive macro ===\n'
rg -n "AttrType::Jid" wacore/derive/src/lib.rs -A30 -B5

printf '\n=== Derived optional JID fields ===\n'
rg -n '#\[attr\(name = ".*", jid, optional\)\]' wacore/src src -C1

Repository: jlucaso1/whatsapp-rust

Length of output: 19667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the complete generated From impl pattern
printf '=== Generated From<NodeRef> impl to see full error handling ===\n'
rg -n "impl.*From.*NodeRef" wacore/derive/src/lib.rs -A100 | head -150

# Check for finish() calls in the generated code
printf '\n=== Check for finish() calls in generated output ===\n'
rg -n "\.finish\(\)" wacore/derive/src/lib.rs

# Look at actual derived structs to see if error handling exists elsewhere
printf '\n=== Sample struct using ProtocolNode derive ===\n'
rg -n "#\[derive.*ProtocolNode" wacore/src -A30 | head -80

# Check if there's error aggregation after attr parsing
printf '\n=== Look for attr_parser usage patterns in generated traits ===\n'
rg -n "attr_parser()" wacore/derive/src/lib.rs -B2 -A10 | head -100

Repository: jlucaso1/whatsapp-rust

Length of output: 129


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the lib.rs file to understand the complete impl generation
wc -l wacore/derive/src/lib.rs

# Show the section where From<NodeRef> is generated (should be around the line with gen_from_impl or similar)
head -300 wacore/derive/src/lib.rs | tail -150

Repository: jlucaso1/whatsapp-rust

Length of output: 7095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where the From<NodeRef> impl is actually generated
printf '=== Searching for fn gen_from_impl or similar ===\n'
rg -n "gen_from_impl|impl.*From.*NodeRef" wacore/derive/src/lib.rs

# Read the section that generates the From impl
printf '\n=== Show complete from impl generation ===\n'
sed -n '600,854p' wacore/derive/src/lib.rs | head -200

Repository: jlucaso1/whatsapp-rust

Length of output: 115


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the middle section of the file to find From impl
sed -n '350,550p' wacore/derive/src/lib.rs

printf '\n=== Now read lines 550-750 ===\n'
sed -n '550,750p' wacore/derive/src/lib.rs | head -150

Repository: jlucaso1/whatsapp-rust

Length of output: 11614


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the actual ProtocolNode impl generation
printf '=== Search for ProtocolNode trait impl generation ===\n'
grep -n "ProtocolNode for" wacore/derive/src/lib.rs

# Look at what comes after field_parsers is collected
printf '\n=== Look at what happens with field_parsers ===\n'
sed -n '265,330p' wacore/derive/src/lib.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2962


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the complete try_from_node_ref impl around line 318-382
printf '=== Complete try_from_node_ref impl ===\n'
sed -n '315,380p' wacore/derive/src/lib.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 1953


Malformed optional JID attributes are silently dropped; required JID attributes mask parse failures as "missing".

optional_jid() records parsing errors in the parser's errors vector but returns None, and the generated try_from_node_ref impl discards the parser without calling .finish(). For optional fields, invalid wire data is silently accepted as absence. For required fields, the .ok_or_else() fallback obscures the parse error as a generic "missing required attribute" message. This weakens validation across all #[derive(ProtocolNode)] types with JID attributes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/derive/src/lib.rs` around lines 185 - 194, The generated code for
AttrType::Jid must not discard parse errors from
node.attr_parser().optional_jid; change both branches that reference
AttrType::Jid so the attr_parser result calls .finish() (or otherwise propagate
parser.finish() errors) instead of dropping the parser or wrapping with
ok_or_else that masks parse failures; for optional fields convert the
parser.finish() result into Option<Jid> (preserving parse errors), and for
required fields call parser.finish() and propagate its error directly rather
than using .ok_or_else("missing..."), ensuring parse errors stored in the
parser.errors vector are surfaced by try_from_node_ref; update the code paths
that build try_from_node_ref to handle the Result from .finish() and return the
parse error instead of hiding it.

Comment thread wacore/src/iq/mediaconn.rs
Comment thread wacore/src/stanza/devices.rs Outdated
Comment on lines +314 to +318
let stanza_id = node
.get_attr("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject device notifications that are missing id.

unwrap_or_default() turns a malformed stanza into stanza_id == "", which makes ACK/dedup logic indistinguishable from a real empty ID. Since this field is the notification’s ACK handle, parse it as required and fail fast instead.

♻️ Minimal fix
-        let stanza_id = node
-            .get_attr("id")
-            .and_then(|v| v.as_str())
-            .unwrap_or_default()
-            .to_string();
+        let stanza_id = required_attr(node, "id")?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/stanza/devices.rs` around lines 314 - 318, The parsing currently
converts a missing or malformed notification id into an empty string via
unwrap_or_default, which masks missing ACK handles; update the code that builds
stanza_id (the node.get_attr("id") ... to_string() logic in stanza/devices.rs)
to treat the id as required and fail fast when absent or not a string—return an
error or early-exit the parsing function (rather than producing stanza_id ==
""), so missing id stanzas are rejected immediately and ACK/dedup logic never
receives an ambiguous empty id.

@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch 4 times, most recently from 6f3fc09 to 178a9b0 Compare April 12, 2026 01:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/history_sync.rs (1)

347-355: 🧹 Nitpick | 🔵 Trivial

Refactor to use the new Server enum for efficient server type checks.

The current implementation uses multiple .is_*() method calls. The PR introduces a Server enum specifically to enable efficient u8==u8 comparisons. Refactoring to use jid.server() would be more performant and align with the new API design:

⚡ Proposed refactor using Server enum
-        // Only 1:1 conversations carry tctokens
-        if jid.is_group() || jid.is_newsletter() || jid.is_bot() {
-            return;
-        }
-
-        let resolved_lid = if jid.is_lid() {
-            None
-        } else {
+        use wacore_binary::Server;
+        
+        // Only 1:1 conversations carry tctokens
+        let resolved_lid = match jid.server() {
+            Server::Group | Server::Newsletter | Server::Bot => return,
+            Server::Lid | Server::HostedLid => None,
+            _ => {
-            self.lid_pn_cache.get_current_lid(&jid.user).await
+                self.lid_pn_cache.get_current_lid(&jid.user).await
+            }
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/history_sync.rs` around lines 347 - 355, Replace the repeated
.is_group()/ .is_newsletter()/ .is_bot() checks with a single fast server
comparison using jid.server() against the Server enum (e.g. Server::Group,
Server::Newsletter, Server::Bot), and similarly replace the .is_lid() check with
jid.server() == Server::Lid when deciding resolved_lid; update the early return
to use jid.server() matches and change the resolved_lid conditional to check
jid.server() == Server::Lid so the code uses u8 comparisons via the new Server
enum instead of the is_*() methods.
wacore/src/iq/prekeys.rs (1)

62-74: ⚠️ Potential issue | 🟠 Major

Don't coerce malformed integer payloads to 0.

extract_content_uint() currently treats missing/non-byte content as 0. In this file that flows into digest parsing, so a malformed <registration> or <list><id> can be accepted as a valid response with fabricated IDs instead of failing fast.

Please make this helper return a Result (or add a strict variant) and propagate that at required call sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/prekeys.rs` around lines 62 - 74, extract_content_uint
currently returns 0 on missing or non-bytes content which masks malformed
payloads; change fn extract_content_uint(node: Option<&NodeRef<'_>>) ->
Result<u32, SomeError> (or a custom error enum) so it returns Ok(u32) for the
NodeContentRef::Bytes path and Err(...) for all other cases instead of
unwrap_or(0); update the body to validate byte length and produce an error for
malformed data, and then propagate this Result at all call sites that parse
digests/registration/list ids (replace existing usages of extract_content_uint
that relied on 0 with ? or explicit error handling in the digest parsing,
registration and list id parsing code paths) so malformed payloads fail fast
rather than being coerced to 0.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Don't silently drop malformed <host> entries.

filter_map(...ok()?) turns host parse failures into missing hosts. That makes a partially invalid media_conn response look successful and can leave the client with an incomplete host list.

🐛 Minimal fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(MediaConnHostExtended::try_from_node_ref)
+            .map(|result| {
+                result.map(|ext| MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<Vec<_>, _>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The current code silently
drops malformed <host> entries because
media_conn_node.get_children_by_tag("host").filter_map(|host_node|
MediaConnHostExtended::try_from_node_ref(host_node).ok().map(|ext| MediaConnHost
{ ... })) hides parse errors; change the logic in the MediaConnHost collection
so that you map each host node to a Result<MediaConnHost, Error> via
MediaConnHostExtended::try_from_node_ref and then either (a) propagate the first
Err upward (change the surrounding function to return a Result) or (b) collect
errors and log/return a consolidated error instead of silently skipping
entries—update the code that constructs hosts (the variable hosts and use of
MediaConnHostExtended::try_from_node_ref) accordingly so malformed host nodes
cause a visible failure or explicit handling.
wacore/src/request.rs (1)

201-226: 🧹 Nitpick | 🔵 Trivial

Drop the boxed Result here.

parse_iq_response() is now paying a heap allocation on every call just to return a small Result. This sits on a hot path and can stay as a plain Result<(), IqError>.

♻️ Suggested fix
-    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Box<Result<(), IqError>> {
+    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Result<(), IqError> {
         if response_node.tag == "stream:error" || response_node.tag == "xmlstreamend" {
-            return Box::new(Err(IqError::Disconnected(response_node.to_owned())));
+            return Err(IqError::Disconnected(response_node.to_owned()));
         }
@@
-                return Box::new(Err(IqError::ServerError { code, text }));
+                return Err(IqError::ServerError { code, text });
             }
-            return Box::new(Err(IqError::ServerError {
+            return Err(IqError::ServerError {
                 code: 0,
                 text: "Malformed error response".to_string(),
-            }));
+            });
         }
 
-        Box::new(Ok(()))
+        Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/request.rs` around lines 201 - 226, The function parse_iq_response
currently returns a boxed Result (Box<Result<(), IqError>>) causing unnecessary
heap allocations; change the signature to return Result<(), IqError> and remove
all Box::new(...) wrappers so each return yields Err(IqError::...) or Ok(())
directly (update the returns in the stream:error/xmlstreamend path, the
error-type branch that builds ServerError, and the final Ok(())); ensure callers
of parse_iq_response are updated to accept a plain Result<(), IqError> and the
IqError variants (Disconnected, ServerError) remain unchanged.
src/handlers/ib.rs (1)

123-133: ⚠️ Potential issue | 🟠 Major

Route edge-routing persistence through DeviceCommand instead of mutating Device directly.

This new path still writes device.edge_routing_info via modify_device, which bypasses the command-based state flow the rest of the repo relies on. Please persist this through DeviceCommand + process_command() instead.

As per coding guidelines "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 123 - 133, The code currently calls
persistence_manager.modify_device(...) inside the async task (see routing_bytes,
client.clone(), client.runtime.spawn and persistence_manager.modify_device)
which mutates Device.edge_routing_info directly; instead build an appropriate
DeviceCommand carrying the routing bytes (e.g. a
SetEdgeRouting/UpdateEdgeRouting command) and call
persistence_manager.process_command(command). Ensure the async move closure uses
client_clone.persistence_manager.process_command(...) and, when reading back
state, use get_device_snapshot() rather than reading Device fields directly.
wacore/src/messages.rs (1)

204-224: ⚠️ Potential issue | 🟡 Minor

Extend Server::Lid checks to include Server::HostedLid for sender_alt mapping.

Lines 204 and 220 only trigger sender_alt population when from.server == Server::Lid, but HostedLid is documented as a LID-based variant. If a self-message arrives with @hosted.lid server, matches_user_or_lid() will recognize it as such (user ID match), but the alternate sender won't be mapped because the server type check excludes it. This leaves the LID-PN cache unwarmed for hosted LID recipients.

Use a helper to check for both variants (e.g., from.is_lid() || from.server == Server::HostedLid) or create an is_lid_like() predicate that treats them equivalently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/messages.rs` around lines 204 - 224, The Sender alternate
(sender_alt) population only checks for from.server == Server::Lid, so messages
from Server::HostedLid are ignored; update the condition used when building
sender_alt (both in the self-message branch and the else branch) to treat
HostedLid the same as Lid—e.g., replace the single-server checks with a helper
predicate like from.is_lid_like() or a condition (from.is_lid() || from.server
== Server::HostedLid) so that sender_alt is set via
own_jid.clone()/own_lid.cloned() or
attrs.optional_jid("sender_pn")/attrs.optional_jid("sender_lid") appropriately;
ensure you reference and update the occurrences around the sender_alt
assignments and related use of matches_user_or_lid().
src/handlers/notification.rs (1)

932-942: ⚠️ Potential issue | 🟠 Major

Don't treat from as an authoritative JID for hash-only picture updates.

When the stanza only carries hash, falling back to from.clone() can emit a PictureUpdate for the wrong contact. This should stay explicitly best-effort only, or be skipped until a contact-hash lookup exists, instead of looking definitive.

Based on learnings: In Rust code path src/handlers/, for hash-based picture notifications, avoid relying on upgrading to a JID via from.clone() as a general fallback. This should be treated as a temporary approximation only in rare edge cases where a contact hash has no JID.

src/message.rs (1)

3577-3607: 🧹 Nitpick | 🔵 Trivial

Don’t reimplement the encryption-JID selection logic inside the tests.

These assertions rebuild the same PN→LID fallback branch structure that production uses, so they can stay green even if resolve_encryption_jid() drifts in exactly the same way. Call the real helper, or extract a shared pure function and reuse it from both production and test code.

Also applies to: 3695-3725, 3796-3827

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 3577 - 3607, The test is duplicating production
logic for choosing the encryption JID; replace the in-test reimplementation with
a call to the shared helper (e.g. resolve_encryption_jid or the existing
handle_incoming_message helper) instead of reconstructing PN→LID fallback logic:
call resolve_encryption_jid(&info.source, &client).await (or extract a small
pure function that takes sender, sender_alt and lid_pn_cache and use that from
both production and tests) so the test exercises the real code paths rather than
mirroring Jid-selection logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/history_sync.rs`:
- Line 341: Remove the redundant explicit type annotation on the jid binding:
change the declaration using conv.id.parse() so the compiler infers the Jid type
instead of specifying wacore_binary::Jid; update the binding named jid (from the
match on conv.id.parse()) to rely on type inference and keep the existing
match/error handling intact.

In `@src/message.rs`:
- Around line 73-74: The code directly inspects
plaintext_node.content.as_deref() and matches on NodeContentRef; replace those
manual matches with the new NodeRef accessors by calling
plaintext_node.content_bytes() (or content_str() where appropriate) and feeding
the returned Option<&[u8]>/Option<&str> into wa::Message::decode or the
corresponding handlers; update the three other occurrences mentioned (around
lines 633-635 and 1009-1010) to use content_bytes()/content_str() instead of
matching NodeContentRef so the file no longer depends on the enum internals.

In `@src/pair.rs`:
- Around line 163-168: The match creates an unnecessary Vec via b.to_vec();
instead return and propagate a borrowed slice and pass that into do_pair_crypto:
change the extraction from
success_node.get_optional_child_by_tag(...).and_then(|n| match
n.content.as_deref() { Some(NodeContentRef::Bytes(b)) => Some(b.to_owned()? ), _
=> None, }) to return the borrowed &[u8] (i.e., Some(b)) and update the variable
name device_identity_bytes to be of type &[u8] (or an Option<&[u8]>), then call
do_pair_crypto(device_identity_bytes, ...) with the borrowed slice; ensure
lifetimes are preserved by keeping the reference live until do_pair_crypto
returns and adjust any wrapper types around success_node if needed.

In `@wacore/binary/src/node.rs`:
- Around line 589-593: Update the OwnedNodeRef doc comment to remove the
absolute claim "zero allocation beyond the buffer itself" and instead state the
precise allocation behavior: explain that OwnedNodeRef lets NodeRef borrow
directly from the decompressed buffer to avoid copying string/byte payloads, but
that NodeRef still owns container allocations (e.g., Vec/Box) for attributes and
children, so callers should expect those allocations; edit the doc comment above
the struct OwnedNodeRef (and any related examples) to reflect this nuance.
- Around line 663-679: OwnedNodeRef currently forwards content_bytes(),
content_str(), and content_nodes() to its inner NodeRef but is missing a
forwarding method for content_as_string(); add a new #[inline] pub fn
content_as_string(&self) -> Option<Cow<'_, str>> (matching the signature on
NodeRef) that simply returns self.get().content_as_string(), and include the
same doc comment style as the existing content_* methods so OwnedNodeRef has
full parity with NodeRef.

In `@wacore/src/media_retry.rs`:
- Around line 178-183: The code forces an owned String via into_owned() for
msg_id even though it is only read/compared in this zero-copy parse path; remove
the allocation by keeping the id borrowed (use the &str or Cow<'_, str> returned
from node.get_attr(...).as_str()) and propagate that borrowed type through the
decryption and validation steps. Update the binding for msg_id to be a borrowed
&str or Cow and adjust any downstream functions called here (the decryption and
validation routines referenced around this use) to accept &str/AsRef<str> (or
Cow) instead of String so you don’t allocate unnecessarily; ensure lifetimes
cover the use until after decryption/validation and remove the into_owned() call
on node.get_attr("id").

In `@wacore/src/types/events.rs`:
- Line 386: The struct field `from` is using the fully qualified type
`wacore_binary::Jid` while `Jid` is already imported and used unqualified
elsewhere; change the field type to the unqualified `Jid` (replace
`wacore_binary::Jid` with `Jid` for the `from` field) so it matches the other
usages and imports (no other changes to imports should be necessary).

---

Outside diff comments:
In `@src/handlers/ib.rs`:
- Around line 123-133: The code currently calls
persistence_manager.modify_device(...) inside the async task (see routing_bytes,
client.clone(), client.runtime.spawn and persistence_manager.modify_device)
which mutates Device.edge_routing_info directly; instead build an appropriate
DeviceCommand carrying the routing bytes (e.g. a
SetEdgeRouting/UpdateEdgeRouting command) and call
persistence_manager.process_command(command). Ensure the async move closure uses
client_clone.persistence_manager.process_command(...) and, when reading back
state, use get_device_snapshot() rather than reading Device fields directly.

In `@src/history_sync.rs`:
- Around line 347-355: Replace the repeated .is_group()/ .is_newsletter()/
.is_bot() checks with a single fast server comparison using jid.server() against
the Server enum (e.g. Server::Group, Server::Newsletter, Server::Bot), and
similarly replace the .is_lid() check with jid.server() == Server::Lid when
deciding resolved_lid; update the early return to use jid.server() matches and
change the resolved_lid conditional to check jid.server() == Server::Lid so the
code uses u8 comparisons via the new Server enum instead of the is_*() methods.

In `@src/message.rs`:
- Around line 3577-3607: The test is duplicating production logic for choosing
the encryption JID; replace the in-test reimplementation with a call to the
shared helper (e.g. resolve_encryption_jid or the existing
handle_incoming_message helper) instead of reconstructing PN→LID fallback logic:
call resolve_encryption_jid(&info.source, &client).await (or extract a small
pure function that takes sender, sender_alt and lid_pn_cache and use that from
both production and tests) so the test exercises the real code paths rather than
mirroring Jid-selection logic.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 375-385: The current code silently drops malformed <host> entries
because media_conn_node.get_children_by_tag("host").filter_map(|host_node|
MediaConnHostExtended::try_from_node_ref(host_node).ok().map(|ext| MediaConnHost
{ ... })) hides parse errors; change the logic in the MediaConnHost collection
so that you map each host node to a Result<MediaConnHost, Error> via
MediaConnHostExtended::try_from_node_ref and then either (a) propagate the first
Err upward (change the surrounding function to return a Result) or (b) collect
errors and log/return a consolidated error instead of silently skipping
entries—update the code that constructs hosts (the variable hosts and use of
MediaConnHostExtended::try_from_node_ref) accordingly so malformed host nodes
cause a visible failure or explicit handling.

In `@wacore/src/iq/prekeys.rs`:
- Around line 62-74: extract_content_uint currently returns 0 on missing or
non-bytes content which masks malformed payloads; change fn
extract_content_uint(node: Option<&NodeRef<'_>>) -> Result<u32, SomeError> (or a
custom error enum) so it returns Ok(u32) for the NodeContentRef::Bytes path and
Err(...) for all other cases instead of unwrap_or(0); update the body to
validate byte length and produce an error for malformed data, and then propagate
this Result at all call sites that parse digests/registration/list ids (replace
existing usages of extract_content_uint that relied on 0 with ? or explicit
error handling in the digest parsing, registration and list id parsing code
paths) so malformed payloads fail fast rather than being coerced to 0.

In `@wacore/src/messages.rs`:
- Around line 204-224: The Sender alternate (sender_alt) population only checks
for from.server == Server::Lid, so messages from Server::HostedLid are ignored;
update the condition used when building sender_alt (both in the self-message
branch and the else branch) to treat HostedLid the same as Lid—e.g., replace the
single-server checks with a helper predicate like from.is_lid_like() or a
condition (from.is_lid() || from.server == Server::HostedLid) so that sender_alt
is set via own_jid.clone()/own_lid.cloned() or
attrs.optional_jid("sender_pn")/attrs.optional_jid("sender_lid") appropriately;
ensure you reference and update the occurrences around the sender_alt
assignments and related use of matches_user_or_lid().

In `@wacore/src/request.rs`:
- Around line 201-226: The function parse_iq_response currently returns a boxed
Result (Box<Result<(), IqError>>) causing unnecessary heap allocations; change
the signature to return Result<(), IqError> and remove all Box::new(...)
wrappers so each return yields Err(IqError::...) or Ok(()) directly (update the
returns in the stream:error/xmlstreamend path, the error-type branch that builds
ServerError, and the final Ok(())); ensure callers of parse_iq_response are
updated to accept a plain Result<(), IqError> and the IqError variants
(Disconnected, ServerError) remain unchanged.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 2303-2307: The code slices off the first byte (bytes[1..]) from
wacore_binary::marshal::marshal_ref(node) which hardcodes the framing; instead,
pass the marshal_ref result through the same unpack helper used elsewhere to
strip/handle any framing/compression before constructing an OwnedNodeRef.
Replace the bytes[1..] usage by calling the common unpack function on the
marshaled bytes and then feed the unpacked/raw protocol bytes into
wacore_binary::OwnedNodeRef::new so ACK waiters use the canonical unpacking
logic rather than assuming a single leading byte.
- Around line 3639-3652: The current code rebuilds JID-backed attributes by
calling get_attr(...).as_str().as_ref() and wrapping into NodeValue, which
forces string materialization; instead preserve the original NodeValue to retain
zero-copy semantics—assign id, from, and participant from node.get_attr(...)
directly (cloning or taking the NodeValue as appropriate) rather than converting
to &str and reconstructing; update the bindings around id, from, and participant
(and any subsequent uses expecting NodeValue) so they use the original NodeValue
from get_attr("id"), get_attr("from"), and get_attr("participant") and avoid
as_str()/NodeValue::from round-trips.
- Around line 81-86: The match logic in NodeFilter::matches is using
attr.as_str(), which misses JID-backed attributes from NodeRef::get_attr and
breaks NodeFilter::from_jid and waiters; update the comparison inside the
attrs.iter().all closure to compare the attribute's canonical/string form (e.g.,
call attr.to_string() or otherwise obtain the attribute's full textual
representation) against v.as_str() (or v.to_string()) instead of relying solely
on attr.as_str(), so JID-backed Attr values are matched; modify the closure in
NodeFilter::matches that calls node.get_attr(...) to use this stringified
comparison.

In `@src/client/device_registry.rs`:
- Around line 301-313: The current delete_sessions_for_devices implementation
cross-products lookup.all_keys() with both Server::Lid and Server::Pn, which
deletes sessions for (user,server) pairs that never existed; instead, inspect
the resolved lookup keys (the UserLookupKeys variants returned by
resolve_lookup_keys) and only purge sessions for the actual lid and pn aliases
with their corresponding servers (e.g., call purge/delete_session for lid with
Server::Lid and pn with Server::Pn), and for the Unknown variant use the
provided caller_server (making the _server argument to clear_device_record
relevant); update delete_sessions_for_devices to iterate the UserLookupKeys
variants rather than using lookup.all_keys() combined with both servers and call
signal_cache.delete_session only for the correct (alias,server,device_id)
combinations.

In `@src/handlers/ib.rs`:
- Around line 113-116: Collapse the nested pattern into a let-chain: replace the
two nested if-let checks (child.get_optional_child("routing_info") and matching
routing_info_node.content.as_deref() to NodeContentRef::Bytes(routing_bytes))
with a single if let chain like "if let Some(routing_info_node) =
child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... }", keeping the same body and
variable names (routing_info_node, routing_bytes) so behavior is unchanged.

In `@src/handlers/message.rs`:
- Around line 116-117: The current hot path clones the Arc unnecessarily with
Arc::clone(&node) when calling tx.try_send; instead move the Arc into the send
to avoid the refcount bump by calling tx.try_send(node) (i.e., pass ownership of
the Arc<Node> rather than cloning it), and ensure `node` is not referenced later
in the function (or handle the returned Err which contains the moved value if
you need to recover it).

In `@src/handlers/notification.rs`:
- Around line 555-558: The closure inside the filter_map is currently truncating
out-of-range key-index values using `v as u32`; change this to a fallible
conversion and drop invalid values by replacing the cast with a try conversion
(e.g., use `u32::try_from(v).ok()` or `v.try_into().ok()`) so the `key_index`
field of AccountSyncDevice only receives valid u32 values; update the
`optional_u64("key-index").map(...)` chain to use `and_then`/`and_then(|v|
u32::try_from(v).ok())` (or equivalent) to filter out invalid/overflowing wire
values.

In `@src/handlers/receipt.rs`:
- Line 28: client.handle_receipt(Arc::clone(&node)) creates an unnecessary Arc
refcount bump; instead move ownership of node into handle_receipt by calling
client.handle_receipt(node). Replace Arc::clone(&node) with node at the call
site referencing the variable named node and ensure no code after this line uses
node (or otherwise adjust control flow); if handle_receipt's signature requires
Arc<Node> already, no signature change is needed—just pass the owned
Arc—otherwise update the caller and any downstream call-sites to accept the
moved Arc in client.handle_receipt so ownership is transferred rather than
cloned.

In `@src/message.rs`:
- Around line 377-385: The current dispatch re-materializes the <enc> subtree
via (*enc_node).to_owned(), negating zero-copy benefits; stop creating an owned
Node at the callsite and instead hand the handler a cheap shared/borrowed
carrier: either wrap the NodeRef in an Arc (e.g., Arc<OwnedNodeRef> or
Arc<NodeRef> plus a selector/path) and pass that (replace enc_node_owned with
enc_node_arc) or change the handler API (handle) to accept a borrowed &NodeRef
so cloning happens only inside handlers that need ownership; update the spawn
block to pass the new carrier (and update handler_clone.handle signature and
implementations) and remove the to_owned() allocation.

In `@src/pair_code.rs`:
- Around line 238-245: The branch building primary_wrapped_ephemeral currently
does b.to_vec() which heap-allocates; instead, after matching
Some(NodeContentRef::Bytes(b)) with b.len() == 80, copy the 80 bytes into a
stack-backed [u8; 80] (e.g., create a [u8;80] and copy_from_slice) and return
that array (or wrap it into the expected type) so decrypt_primary_ephemeral_pub
receives a fixed-size array without a Vec allocation; update the match arm in
the reg_node.get_optional_child_by_tag(...) handling that produces
primary_wrapped_ephemeral accordingly.

In `@src/pair.rs`:
- Around line 199-206: The code currently uses unwrap_or_default() and a default
fallback which allows empty JIDs to be persisted; instead, when extracting
device JIDs from success_node (via device_node.attrs().optional_jid("jid") and
optional_jid("lid")), explicitly validate that both jid and lid are present and
well-formed and reject the pair-success stanza if either is missing or
malformed; do not return Jid::default() in the else branch—propagate an
error/early-return so SetId/SetLid cannot be called with empty identifiers.
- Around line 54-57: The code eagerly clones the byte payload with
bytes.to_vec() before validating UTF-8; instead keep the slice borrowed and
validate with std::str::from_utf8 on the bytes from grandchild.content (match
NodeContentRef::Bytes via as_deref()), then only allocate the owned String at
the final boundary when calling PairUtils::make_qr_data (e.g., call
s.to_string() or String::from(s)); update the match around grandchild.content,
the UTF-8 check (replace String::from_utf8(bytes.to_vec()) with
std::str::from_utf8(bytes)), and pass the owned String to
PairUtils::make_qr_data.

In `@src/retry.rs`:
- Line 229: handle_retry_receipt() already resolves the participant JID before
calling process_retry_key_bundle(), so remove the redundant
resolve_encryption_jid() calls inside process_retry_key_bundle() (and the other
duplicate spots at the same call sites) and reuse the resolved_jid passed in;
update process_retry_key_bundle() (and any overloads or callers at the noted
locations) to accept and use the already-resolved_jid instead of calling
resolve_encryption_jid() again, ensuring any places that still call
resolve_encryption_jid() are changed to pass the resolved_jid through.
- Around line 123-129: The code currently reads participant into an owned string
(participant_str) by using nr.get_attr("participant") or
receipt.source.sender.to_string() and later reparses it to a Jid, causing extra
allocations and potential divergence between the dedupe key and the actual Jid
used; change the logic in the branches around is_group_or_status so you parse
the participant once into a Jid (use the JID-aware accessor instead of
get_attr("participant") -> as_str -> into_owned) and reuse that parsed Jid for
building the dedupe key and any subsequent operations (update usages that
currently rely on participant_str in functions/methods that create the dedupe
key and where you later reparse to Jid), applying the same change to the other
occurrences noted (the other blocks that currently create participant_str).

In `@src/test_utils.rs`:
- Around line 8-10: Check that the marshalled Vec returned from
wacore_binary::marshal::marshal has at least one byte (guard bytes.len() >= 1)
to avoid panics, then avoid the extra copy by reusing the existing Vec buffer
instead of bytes[1..].to_vec(); e.g., take ownership of the Vec and use
Vec::split_off(1) (or otherwise remove the first byte into a separate variable)
and pass the resulting Vec into OwnedNodeRef::new so
Arc::new(OwnedNodeRef::new(...).expect(...)) receives the owned raw-protocol
bytes without an extra allocation.

In `@wacore/binary/src/decoder.rs`:
- Around line 109-115: Add a regression test that decodes a JID_PAIR containing
an unknown server and asserts that decode returns
Err(BinaryError::AttrParse(_)); specifically exercise the decoder path in
read_jid_pair by providing a user and a server token that cannot be parsed by
crate::jid::Server::try_from and assert the error matches BinaryError::AttrParse
so the new fail-fast behavior in read_jid_pair (and the JID_PAIR handling)
cannot regress.
- Around line 132-143: The match on `agent` currently assigns unsupported domain
bytes to `server` as `crate::jid::Server::Pn`, which coerces malformed wire
data; instead change the fallback arm (the `_` case in the `let server = match
agent { ... }`) to return a decoding error (e.g.,
Err(DecodeError::UnsupportedAdJid(agent)) or propagate an appropriate error
type) rather than mapping to `Server::Pn`, and update the surrounding function
signature/return path to propagate that error so malformed AD_JID bytes fail
fast.

In `@wacore/binary/src/jid.rs`:
- Around line 508-512: The actual_agent() method currently only strips the agent
for Server::Pn causing inconsistencies for agent-less server variants; update
the match on self.server in actual_agent() to return 0 for all agent-less server
variants (e.g., Server::Pn, Server::Lid, Server::Hosted, Server::HostedLid or
whatever enum variants represent `@lid`, `@hosted`, `@hosted.lid`) so callers get a
zero agent for those servers and remain consistent with the file's
parsing/formatting logic.

In `@wacore/derive/src/lib.rs`:
- Around line 185-194: The JID branches currently call
node.attrs().optional_jid(`#attr_name`) directly and thus drop parse errors;
change both branches in the AttrType::Jid cases so they call .finish() on the
parser and propagate its Result: for the required variant call
node.attrs().optional_jid(`#attr_name`).finish()?.ok_or_else(||
::anyhow::anyhow!("missing required attribute '{}'", `#attr_name`))? (so parsing
errors bubble up before checking presence) and for the optional variant call
node.attrs().optional_jid(`#attr_name`).finish()? (so malformed JIDs return an Err
instead of being treated as None); apply the same change to the other Jid branch
occurrences (e.g., the 329-336 block) in the generated try_from_node_ref
implementation.

In `@wacore/src/appstate_sync.rs`:
- Around line 98-156: The decode_patch_list_ref and decode_patch_list functions
duplicate the snapshot and external mutation hydration logic; extract that
shared logic into a new async helper (e.g., hydrate_and_process_patch_list) that
takes &self, a parsed PatchList, the validate_macs flag, and the download
closure (same FDownload type), performs the snapshot_ref download/decoding and
the per-patch external_mutations download/decoding loop, then calls
self.process_patch_list(pl, validate_macs). Replace the bodies of
decode_patch_list_ref and decode_patch_list to parse their PatchList variant,
call hydrate_and_process_patch_list(parsed_pl, validate_macs, download). Ensure
the helper returns the same Result<(Vec<Mutation>, HashState, PatchList)> and
preserves existing log messages and error handling.

In `@wacore/src/iq/blocklist.rs`:
- Around line 116-131: The parse_response implementation duplicates the
<list>/<item> traversal and warning handling that
BlocklistResponse::try_from_node_ref already encapsulates; replace the current
manual traversal in parse_response with a single call to
BlocklistResponse::try_from_node_ref (or the associated conversion method) on
the incoming response and return its Result, so parsing and warnings remain
centralized in BlocklistResponse::try_from_node_ref rather than reimplementing
the logic here; remove the filter_map/collect duplication and rely on
BlocklistResponse to produce the Vec<BlocklistEntry>.

In `@wacore/src/iq/business.rs`:
- Around line 55-59: The match arm in node_text with NodeContentRef::Bytes
currently clones bytes via b.to_vec() before UTF-8 checking; change it to
validate the borrowed bytes directly and allocate only once by using
std::str::from_utf8(b).ok().map(|s| s.to_string()) in the NodeContentRef::Bytes
arm of the node_text function so you avoid the extra Vec allocation.

In `@wacore/src/iq/chatstate.rs`:
- Around line 140-147: The parse() branch using attrs.optional_jid("from")
collapses malformed and missing JIDs into None so InvalidJid never surfaces;
update chatstate::parse (the match on attrs.optional_jid("from")) to distinguish
parse failures from missing values by either calling the required parser (e.g.,
attrs.jid("from") or a method that returns Result) and map Err to
ChatstateParseError::InvalidJid, or after optional_jid returns None inspect the
attrs parser errors/state (from optional_jid implementation) and if a JID parse
error is recorded return ChatstateParseError::InvalidJid, otherwise keep the
MissingFrom/SelfEcho logic; reference optional_jid and parse() when making the
change.

In `@wacore/src/iq/groups.rs`:
- Around line 379-382: The code currently treats any unexpected participant
"type" value as ParticipantType::Member by chaining
optional_string(...).and_then(...).unwrap_or(...); change this so that missing
attribute still defaults to ParticipantType::Member but an invalid/unknown
string returns an error instead of being coerced. Replace the chain around
attrs.optional_string("type") and ParticipantType::try_from(...) with a match or
map that: if optional_string("type") is None => ParticipantType::Member, if
Some(s) => propagate the Result from ParticipantType::try_from(s.as_ref()) (i.e.
return Err on failure) so parsing fails on unknown values; update the
surrounding function to return the proper Result type if needed.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 303-323: In try_from_node_ref (the function building
MediaConnResponseExtended), ensure attribute parse failures are propagated by
calling attrs.finish() and returning its Err before constructing/returning the
MediaConnResponseExtended; i.e., after parsing
ttl/auth_ttl/max_buckets/set_ip_token and before creating the
MediaConnResponseExtended (or after collecting hosts via
MediaConnHostExtended::try_from_node_ref), call attrs.finish().map_err(|e|
anyhow!(e))? (or otherwise propagate the error) so malformed numeric attributes
don't silently become 0/None.

In `@wacore/src/iq/node.rs`:
- Around line 22-25: The helper required_attr currently forces an allocation by
returning String; change its signature and implementation to return a
borrowed-friendly type (e.g., Cow<'_, str> or &str consistent with
optional_attr) so callers can avoid copies; update pub(crate) fn
required_attr(node: &NodeRef<'_>, key: &str) -> Result<Cow<'_, str>,
anyhow::Error> (or Result<&str, anyhow::Error> to match optional_attr) and use
node.get_attr(key).map(Cow::from).ok_or_else(|| anyhow!("missing required
attribute {key}")) so the attribute value is returned without unnecessary
allocation. Ensure any call sites of required_attr are adjusted to accept
Cow<'_, str> (or &str) as needed.

In `@wacore/src/iq/tctoken.rs`:
- Around line 281-287: The code currently allocates and reparses the jid by
calling token_node.get_attr("jid").map(|v| v.as_str().into_owned()).parse(),
which defeats the NodeRef migration performance goal; instead, use the attribute
API's typed JID conversion directly (e.g., call the NodeRef/Attribute method
that returns a Jid or result without into_owned/parse) so you read token_node's
"jid" attribute as a Jid in-place and propagate errors via map_err/ok_or_else as
before; replace the jid_str/jid parsing logic (symbols: token_node, get_attr,
jid_str, parse, Jid) with the direct-typed-attribute call to avoid allocation
and reparse.

In `@wacore/src/iq/usync.rs`:
- Around line 121-128: The parse_lid_jid helper is currently calling
attrs().optional_string("val") and then parsing that string into a Jid, which
defeats the zero-copy optimization; replace that code path by calling
attrs().optional_jid("val") to return an Option<Jid> directly. Update the
similar branches that use attrs().optional_string(...).and_then(|s|
s.parse::<Jid>().ok()) (e.g., the places handling jid, pn_jid and other val
attributes) to use attrs().optional_jid(...) instead so you avoid the
intermediate allocation and re-parse.

In `@wacore/src/pair_code.rs`:
- Around line 32-35: Replace the remaining string-based SERVER_JID usage in
pair_code.rs with the typed Server enum: import wacore_binary::Server and change
any occurrences that call SERVER_JID or SERVER_JID.to_string() in the
IQ-building code (the pair-code IQ constructors referenced around Server
targeting) to use Server::Pn instead so the builders target Server::Pn via the
new typed API; update any function parameters or Node/NodeBuilder calls that
previously accepted a String JID to accept the Server value (or convert via the
existing Server -> target API) in the same spots flagged (around the imports and
the IQ builder blocks).

In `@wacore/src/pair.rs`:
- Around line 85-98: Both build_ack_node_ref and build_ack_node construct
identical IQ "result" nodes; consolidate their logic into a single private
helper (e.g., fn make_ack_node(to: &str, id: &str) -> Node) and have
build_ack_node_ref extract "to" and "id" via request_node.get_attr(...) then
call that helper, while build_ack_node likewise forwards its to/id to the same
helper; update callers to use the unified helper through the two public
functions and remove duplicated NodeBuilder construction currently in
build_ack_node_ref and build_ack_node.

In `@wacore/src/prekeys.rs`:
- Around line 144-147: The function extract_bytes_ref currently returns Vec<u8>,
causing unnecessary heap allocations in the zero-copy parser; change its
signature to return Result<&[u8], anyhow::Error> and have it match
Some(NodeContentRef::Bytes(b)) => Ok(b) (without to_vec()). Update all callsites
in the prekey parsing code (the branches that build value and signature arrays
and any other uses noted around the value/signature handling) to accept the byte
slice, check its length, and perform a single copy into the fixed-size arrays
([u8; 32], [u8; 64]) at the final boundary instead of allocating intermediate
Vecs; ensure lifetimes from NodeRef are preserved so the returned &[u8] remains
valid for the copy.

In `@wacore/src/stanza/devices.rs`:
- Around line 317-321: The code currently uses node.get_attr("id").map(|v|
v.as_str()).unwrap_or_default().into_owned() to produce stanza_id, which
collapses missing/malformed IDs to ""—instead require and validate the id:
replace the unwrap_or_default usage for stanza_id with explicit handling of
node.get_attr("id") (and the resulting &str) and if it's missing or empty
return/propagate an error (or return None) and log context so malformed
notifications are rejected before ACK/dedup logic; reference the stanza_id
variable and the node.get_attr("id") call when making this change so the
function (the caller handling ACK/dedup) stops processing notifications with
absent/empty ids.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2b61036e-a577-474e-a47b-f3fd8994bd21

📥 Commits

Reviewing files that changed from the base of the PR and between 951c6d5 and 5a19d4b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (119)
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/benches/binary_benchmark.rs
  • wacore/binary/src/attrs.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs
  • wacore/tests/binary_protocol_test.rs

Comment thread src/history_sync.rs

// Resolve to LID for storage key consistency with notification handler
let jid: wacore_binary::jid::Jid = match conv.id.parse() {
let jid: wacore_binary::Jid = match conv.id.parse() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider removing redundant type annotation.

The explicit wacore_binary::Jid type annotation can be inferred from .parse(). Removing it would reduce verbosity:

-        let jid: wacore_binary::Jid = match conv.id.parse() {
+        let jid = match conv.id.parse() {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let jid: wacore_binary::Jid = match conv.id.parse() {
let jid = match conv.id.parse() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/history_sync.rs` at line 341, Remove the redundant explicit type
annotation on the jid binding: change the declaration using conv.id.parse() so
the compiler infers the Jid type instead of specifying wacore_binary::Jid;
update the binding named jid (from the match on conv.id.parse()) to rely on type
inference and keep the existing match/error handling intact.

Comment thread src/message.rs Outdated
Comment thread src/pair.rs Outdated
Comment thread wacore/binary/src/node.rs
Comment thread wacore/binary/src/node.rs
Comment thread wacore/src/media_retry.rs
Comment on lines 178 to 183
let msg_id = node
.attrs()
.optional_string("id")
.get_attr("id")
.map(|v| v.as_str())
.ok_or_else(|| anyhow!("notification missing 'id' attribute"))?
.to_string();
.into_owned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Avoid forcing an owned msg_id in this zero-copy parse path.

into_owned() allocates even though the id is only read/compared in-scope. Keep it borrowed (Cow/&str) through decryption and validation.

♻️ Proposed change
-    let msg_id = node
-        .get_attr("id")
-        .map(|v| v.as_str())
-        .ok_or_else(|| anyhow!("notification missing 'id' attribute"))?
-        .into_owned();
+    let msg_id = node
+        .get_attr("id")
+        .map(|v| v.as_str())
+        .ok_or_else(|| anyhow!("notification missing 'id' attribute"))?;
@@
-    let notification = decrypt_media_retry_notification(media_key, &msg_id, enc_iv, enc_p)?;
+    let notification =
+        decrypt_media_retry_notification(media_key, msg_id.as_ref(), enc_iv, enc_p)?;
@@
-    if let Some(ref returned_id) = notification.stanza_id
-        && returned_id != &msg_id
+    if let Some(ref returned_id) = notification.stanza_id
+        && returned_id != msg_id.as_ref()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 178 - 183, The code forces an owned
String via into_owned() for msg_id even though it is only read/compared in this
zero-copy parse path; remove the allocation by keeping the id borrowed (use the
&str or Cow<'_, str> returned from node.get_attr(...).as_str()) and propagate
that borrowed type through the decryption and validation steps. Update the
binding for msg_id to be a borrowed &str or Cow and adjust any downstream
functions called here (the decryption and validation routines referenced around
this use) to accept &str/AsRef<str> (or Cow) instead of String so you don’t
allocate unnecessarily; ensure lifetimes cover the use until after
decryption/validation and remove the into_owned() call on node.get_attr("id").

Comment thread wacore/src/types/events.rs Outdated
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from 178a9b0 to fafb553 Compare April 12, 2026 01:27

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fafb5538c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/jid.rs
Comment on lines +168 to +169
#[default]
Pn = 0,

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep default JID server empty instead of PN

Making Server::Pn the enum default changes Jid::default() from an empty sentinel to a valid-looking s.whatsapp.net JID, which alters behavior anywhere missing/invalid required JIDs fall back to unwrap_or_default() (for example AttrParserRef::jid). Those call sites can now be routed through PN-specific branches instead of staying obviously empty, so malformed stanzas may be processed as normal PN traffic rather than rejected or ignored.

Useful? React with 👍 / 👎.

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
src/spam_report.rs (1)

88-88: ⚠️ Potential issue | 🟡 Minor

Use obviously fictitious phone numbers in test code.

The phone number "5511999887766" follows a realistic Brazilian mobile number format (country code 55, São Paulo area code 11, 9-digit local number) and could potentially be real PII. Based on learnings, test code should use clearly fictitious values to avoid any risk of including real user data.

🛡️ Suggested fix to use obviously fictitious values
-            from_jid: Some(Jid::pn("5511999887766")),
+            from_jid: Some(Jid::pn("1234567890")),

And at line 114:

-            participant_jid: Some(Jid::pn("5511999887766")),
+            participant_jid: Some(Jid::pn("1234567890")),

Also applies to: 114-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/spam_report.rs` at line 88, The test uses a realistic phone number in the
Jid construction (from_jid: Some(Jid::pn("5511999887766"))); replace that
literal with an obviously fictitious phone number in both occurrences (the
from_jid Jid::pn call at line 88 and the similar call at line 114) — e.g., use a
clearly fake pattern such as repeating zeros or a reserved test prefix — so
update the string passed to Jid::pn accordingly to remove any potential real
PII.
src/handlers/ib.rs (1)

123-133: 🛠️ Refactor suggestion | 🟠 Major

Avoid mutating Device directly from this spawned task.

This still writes edge_routing_info through modify_device(...). Please route the update through a DeviceCommand so it stays on the repository’s device-state mutation path.

As per coding guidelines "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 123 - 133, The spawned task must not call
modify_device(...) directly; instead build a DeviceCommand that carries the
routing_bytes and send it through the repository mutation path by calling
client_clone.persistence_manager.process_command(command).await (construct the
appropriate variant, e.g., a SetEdgeRoutingInfo or UpdateEdgeRouting command
containing routing_bytes), and if you need to read state do so via
client_clone.persistence_manager.get_device_snapshot(); replace the
modify_device(...) invocation inside the client.runtime.spawn block with this
process_command(...) flow.
wacore/src/request.rs (1)

201-226: 🧹 Nitpick | 🔵 Trivial

Drop the heap allocation from parse_iq_response.

Box<Result<(), IqError>> allocates on every IQ response, but this helper only returns a small enum. Returning Result<(), IqError> directly keeps the parse path allocation-free.

♻️ Suggested change
-    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Box<Result<(), IqError>> {
+    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Result<(), IqError> {
         if response_node.tag == "stream:error" || response_node.tag == "xmlstreamend" {
-            return Box::new(Err(IqError::Disconnected(response_node.to_owned())));
+            return Err(IqError::Disconnected(response_node.to_owned()));
         }

         if let Some(res_type) = response_node.get_attr("type")
             && res_type.as_str() == "error"
         {
             let error_child = response_node.get_optional_child_by_tag(&["error"]);
             if let Some(error_node) = error_child {
                 let mut parser = error_node.attrs();
                 let code = parser.optional_u64("code").unwrap_or(0) as u16;
                 let text = parser
                     .optional_string("text")
                     .as_deref()
                     .unwrap_or("")
                     .to_string();
-                return Box::new(Err(IqError::ServerError { code, text }));
+                return Err(IqError::ServerError { code, text });
             }
-            return Box::new(Err(IqError::ServerError {
+            return Err(IqError::ServerError {
                 code: 0,
                 text: "Malformed error response".to_string(),
-            }));
+            });
         }

-        Box::new(Ok(()))
+        Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/request.rs` around lines 201 - 226, The parse_iq_response function
currently returns Box<Result<(), IqError>> causing unnecessary heap allocations;
change its signature to return Result<(), IqError> and remove all Box::new
wrappers: replace every return Box::new(Err(...)) with Err(...) and the final
Box::new(Ok(())) with Ok(()). Update the function declaration
parse_iq_response(&self, response_node: &NodeRef<'_>) -> Result<(), IqError> and
ensure any callers of parse_iq_response are adjusted to accept a Result instead
of a boxed Result.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Don't silently drop malformed <host> entries.

This filter_map(...ok()? ...) turns host-parse failures into “host absent”, so a bad <host> node yields a partial/empty host list instead of rejecting the response. That can hide server/data regressions and leave callers with an unusable media host set.

Suggested fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(|host_node| {
+                let ext = MediaConnHostExtended::try_from_node_ref(host_node)?;
+                Ok(MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<_, anyhow::Error>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The code silently drops
malformed <host> entries by using filter_map with
MediaConnHostExtended::try_from_node_ref(...).ok() which hides parse errors;
instead map each host_node into a Result<MediaConnHost, _> via
MediaConnHostExtended::try_from_node_ref(host_node).map(|ext| MediaConnHost {
hostname: ext.hostname, host_type: ext.host_type, fallback_hostname:
ext.fallback_hostname }), then collect the iterator into a
Result<Vec<MediaConnHost>, _> (e.g. .collect::<Result<Vec<_>, _>>() or using
try_collect) and propagate the error from the enclosing function (adjust the
return type to Result if necessary) so malformed host entries cause a failure
rather than being dropped silently.
src/handlers/message.rs (1)

77-118: ⚠️ Potential issue | 🟠 Major

Recreate the per-chat queue when the cached sender is closed.

The worker intentionally exits on a generation change, which drops rx but leaves the cached tx alive until cache expiry. After a reconnect, get_with_by_ref can hand back that closed sender and Line 117 just logs the try_send failure, so messages for that chat are dropped instead of starting a fresh worker. Invalidate/rebuild the queue under the same enqueue lock and retry once on TrySendError::Closed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.rs` around lines 77 - 118, The cached per-chat sender
(tx) can be closed after a worker exits on generation change, causing
tx.try_send(node) to fail and silently drop messages; modify the enqueue path
that uses get_with_by_ref so that when tx.try_send returns
Err(TrySendError::Closed) you invalidate/rebuild the per-chat queue under the
same enqueue lock (recreate tx/rx and respawn the worker using the existing
spawn_generation logic), then retry the send once; ensure you only retry once to
avoid races and preserve existing locking behavior around the queue creation.
wacore/src/usync.rs (1)

163-183: 🧹 Nitpick | 🔵 Trivial

Avoid reparsing JIDs from strings on this zero-copy path.

optional_string("jid") + parse::<Jid>() and the same pattern for <lid val="..."> reintroduce string materialization/parsing right in the new NodeRef parser. Prefer the typed attr accessors (optional_jid("jid"), optional_jid("val")) so this stays allocation-light and keeps the Server-typed behavior consistent with the rest of the migration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/usync.rs` around lines 163 - 183, The code is reparsing JID
strings via user_node.attrs().optional_string("jid") and
lid_node.attrs().optional_string("val") then parse::<Jid>(), which breaks the
zero-copy path; replace those with the typed accessors
user_node.attrs().optional_jid("jid") and lid_node.attrs().optional_jid("val")
(or the node-level optional_jid helpers) and then use the returned Jid directly
when checking user_jid.server and lid_jid.server so you avoid allocation/parsing
and keep Server-typed behavior consistent with the rest of the NodeRef parser.
wacore/src/stanza/business.rs (1)

187-336: 🛠️ Refactor suggestion | 🟠 Major

Collapse these newly touched NodeRef branches into let-chains.

The remove, verified_name, profile, and subscriptions paths reintroduce nested if let / else if let blocks. Please collapse them into let-chains so the parser keeps the repo’s preferred collapsible-if style throughout the migration. As per coding guidelines, "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/stanza/business.rs` around lines 187 - 336, The code reintroduces
nested if-let blocks for node children ("remove", "verified_name", "profile",
"subscriptions"); collapse them into let-chains so the parser keeps the
collapsible-if style: for each branch (remove_node, vn_node, profile_node,
subs_node) use combined let-chains that bind the child and the needed attrs in a
single if-let Some(...) = ... && let Some(...) = ... pattern, then return the
appropriate BusinessNotificationType (e.g., BusinessNotificationType::RemoveJid
/ RemoveHash, VerifiedNameJid / VerifiedNameHash using
VerifiedName::try_from_node, Profile / ProfileHash, Subscriptions) and construct
the vectors/BusinessSubscription entries as before; reference the existing
symbols remove_node, vn_node, profile_node, subs_node, BusinessNotificationType,
and VerifiedName::try_from_node to locate and refactor each block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 4900-4902: Remove the local shim function node_to_owned_ref in
this module and instead import and use the shared helper from
crate::test_utils::node_to_owned_ref directly; delete the fn
node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef> wrapper, add a
use/import for crate::test_utils::node_to_owned_ref at the top of the file (or
replace local calls to call crate::test_utils::node_to_owned_ref) and update any
call sites in this file to reference the shared helper.
- Around line 2301-2317: The code removes the waiter from response_waiters
before attempting the fallible re-encoding (marshal_ref / OwnedNodeRef::new),
causing dropped senders on encode failure; change the logic so the waiter is
only removed after successful materialization of the payload (i.e., lock
response_waiters, get or clone the waiter without removing it, perform
marshal_ref(...) and OwnedNodeRef::new(...), and only call remove(&id) after Ok
and successful waiter.send(Arc::new(onr))); alternatively, if you prefer to keep
removal early, explicitly send an error state to the waiter in the Err branch
(using the same waiter variable) before or instead of removing it, referencing
response_waiters, marshal_ref, OwnedNodeRef::new, and waiter.send to locate the
code to modify.

In `@wacore/binary/src/jid.rs`:
- Line 627: The fast-path parser parse_jid_fast() is currently collapsing
malformed numeric parts to 0 so the new numeric overflow/format validation never
runs; modify parse_jid_fast() to detect and reject malformed numeric components
(e.g., non-digit characters or out-of-range values) by returning None instead of
silently converting them, so the code paths that call
Server::try_from(parts.server)? and the fallback validation logic will run; also
apply the same change to other fast-path uses referenced around the numeric
handling sites (the other parse_jid_fast call sites noted) so malformed inputs
like "123:abc@s.whatsapp.net" and "user.300@hosted" fall back to proper
validation and produce errors rather than turning into valid JIDs.

In `@wacore/src/appstate_sync.rs`:
- Around line 98-107: The decode_patch_list_ref and decode_multi_patch_list_ref
entrypoints are intended to be zero-copy but still trigger a deep copy because
parse_patch_list_ref and parse_patch_lists_ref call node.to_owned(); update
those parse_* functions to accept NodeRef<'_> (or otherwise parse from
references) and remove the node.to_owned() allocation so parsing operates
directly on the borrowed node tree; then adjust the callers
(decode_patch_list_ref, decode_multi_patch_list_ref) to pass the NodeRef through
to the new reference-based parse functions and ensure signature and trait bounds
(if any) are updated accordingly to preserve borrow lifetimes and avoid cloning
the stanza.

In `@wacore/src/media_retry.rs`:
- Around line 208-211: In decrypt_media_retry_notification(), validate the
enc_iv bytes before calling Nonce::from_slice to avoid a panic: after obtaining
enc_iv from the encrypt_node (the enc_iv variable) check that enc_iv.len() == 12
and return an Err(anyhow!("invalid enc_iv length: {} (expected 12)",
enc_iv.len())) (or similar error) if not; this turns the panic into a handled
error path and prevents malformed input from causing a crash.

In `@wacore/src/prekeys.rs`:
- Around line 137-139: Rename the three private helper functions to remove the
trailing "_ref" suffix: change node_to_pre_key_bundle_ref to
node_to_pre_key_bundle, node_to_pre_key_ref to node_to_pre_key, and
node_to_signed_pre_key_ref to node_to_signed_pre_key; update all internal
definitions and every call site (including the group around the other
occurrences referenced) to use the new names, preserving the existing signatures
(jid: &Jid, node: &NodeRef<'_>, etc.) and visibility, and adjust any
comments/docs that mention the old names so callers compile without API noise.

---

Outside diff comments:
In `@src/handlers/ib.rs`:
- Around line 123-133: The spawned task must not call modify_device(...)
directly; instead build a DeviceCommand that carries the routing_bytes and send
it through the repository mutation path by calling
client_clone.persistence_manager.process_command(command).await (construct the
appropriate variant, e.g., a SetEdgeRoutingInfo or UpdateEdgeRouting command
containing routing_bytes), and if you need to read state do so via
client_clone.persistence_manager.get_device_snapshot(); replace the
modify_device(...) invocation inside the client.runtime.spawn block with this
process_command(...) flow.

In `@src/handlers/message.rs`:
- Around line 77-118: The cached per-chat sender (tx) can be closed after a
worker exits on generation change, causing tx.try_send(node) to fail and
silently drop messages; modify the enqueue path that uses get_with_by_ref so
that when tx.try_send returns Err(TrySendError::Closed) you invalidate/rebuild
the per-chat queue under the same enqueue lock (recreate tx/rx and respawn the
worker using the existing spawn_generation logic), then retry the send once;
ensure you only retry once to avoid races and preserve existing locking behavior
around the queue creation.

In `@src/spam_report.rs`:
- Line 88: The test uses a realistic phone number in the Jid construction
(from_jid: Some(Jid::pn("5511999887766"))); replace that literal with an
obviously fictitious phone number in both occurrences (the from_jid Jid::pn call
at line 88 and the similar call at line 114) — e.g., use a clearly fake pattern
such as repeating zeros or a reserved test prefix — so update the string passed
to Jid::pn accordingly to remove any potential real PII.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 375-385: The code silently drops malformed <host> entries by using
filter_map with MediaConnHostExtended::try_from_node_ref(...).ok() which hides
parse errors; instead map each host_node into a Result<MediaConnHost, _> via
MediaConnHostExtended::try_from_node_ref(host_node).map(|ext| MediaConnHost {
hostname: ext.hostname, host_type: ext.host_type, fallback_hostname:
ext.fallback_hostname }), then collect the iterator into a
Result<Vec<MediaConnHost>, _> (e.g. .collect::<Result<Vec<_>, _>>() or using
try_collect) and propagate the error from the enclosing function (adjust the
return type to Result if necessary) so malformed host entries cause a failure
rather than being dropped silently.

In `@wacore/src/request.rs`:
- Around line 201-226: The parse_iq_response function currently returns
Box<Result<(), IqError>> causing unnecessary heap allocations; change its
signature to return Result<(), IqError> and remove all Box::new wrappers:
replace every return Box::new(Err(...)) with Err(...) and the final
Box::new(Ok(())) with Ok(()). Update the function declaration
parse_iq_response(&self, response_node: &NodeRef<'_>) -> Result<(), IqError> and
ensure any callers of parse_iq_response are adjusted to accept a Result instead
of a boxed Result.

In `@wacore/src/stanza/business.rs`:
- Around line 187-336: The code reintroduces nested if-let blocks for node
children ("remove", "verified_name", "profile", "subscriptions"); collapse them
into let-chains so the parser keeps the collapsible-if style: for each branch
(remove_node, vn_node, profile_node, subs_node) use combined let-chains that
bind the child and the needed attrs in a single if-let Some(...) = ... && let
Some(...) = ... pattern, then return the appropriate BusinessNotificationType
(e.g., BusinessNotificationType::RemoveJid / RemoveHash, VerifiedNameJid /
VerifiedNameHash using VerifiedName::try_from_node, Profile / ProfileHash,
Subscriptions) and construct the vectors/BusinessSubscription entries as before;
reference the existing symbols remove_node, vn_node, profile_node, subs_node,
BusinessNotificationType, and VerifiedName::try_from_node to locate and refactor
each block.

In `@wacore/src/usync.rs`:
- Around line 163-183: The code is reparsing JID strings via
user_node.attrs().optional_string("jid") and
lid_node.attrs().optional_string("val") then parse::<Jid>(), which breaks the
zero-copy path; replace those with the typed accessors
user_node.attrs().optional_jid("jid") and lid_node.attrs().optional_jid("val")
(or the node-level optional_jid helpers) and then use the returned Jid directly
when checking user_jid.server and lid_jid.server so you avoid allocation/parsing
and keep Server-typed behavior consistent with the rest of the NodeRef parser.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 81-86: NodeFilter::matches currently compares only the attribute's
string-backed view which breaks JID-backed attrs; update the closure in matches
(the node.get_attr(...) check) to accept either the string form or the JID form
when comparing to self.attrs values: retrieve the attr via node.get_attr(k) and
return true if attr.as_str() == v.as_str() OR if the attr exposes a JID form
(e.g. attr.as_jid()/to_jid()) whose string representation equals v.as_str();
adjust the predicate used in self.attrs.iter().all(...) accordingly so
NodeFilter::matches handles both plain-string and JID-backed attribute values.
- Around line 3638-3651: In build_ack_node, avoid converting JID-backed attrs to
String via as_str()/as_ref(); instead construct NodeValue directly from the
borrowed attribute value returned by node.get_attr so you keep the zero-copy
borrow. Replace uses like node.get_attr("id")?.as_str().as_ref() and map(|v|
NodeValue::from(v.as_str().as_ref())) for "id", "from", "participant" and the
"type" branch with direct passes of the attribute (e.g.
node.get_attr("id").map(|v| NodeValue::from(v)) or
NodeValue::from(v.as_ref()/v.as_bytes() as appropriate to NodeValue's borrow
API) so no intermediate string allocation occurs; apply the same pattern in the
typ construction while still using is_encrypt_identity_notification(node) and
respecting own_device_pn.

In `@src/client/device_registry.rs`:
- Around line 240-241: The loop that calls self.clear_device_record(user,
device.jid.server.as_str(), &record) is over-broad: when lookup.all_keys()
returns mapped keys it yields both LID and PN aliases but the code then pairs
each key with both Server::Lid and Server::Pn, deleting four addresses instead
of the real two; also Unknown users must use the caller's server rather than
defaulting. Change the logic that iterates over lookup.all_keys(): match on each
UserLookupKeys variant and only construct (user, server) pairs that are valid
for that variant (e.g., for a LID key only use Server::Lid, for a PN key only
use Server::Pn), and for UserLookupKeys::Unknown use the incoming
device.jid.server (the caller’s server) when calling clear_device_record; update
the same pattern in the block handling lines ~301-313 as well.

In `@src/handlers/ib.rs`:
- Around line 113-116: Replace the nested `if let` blocks by collapsing them
into a let-chain: combine the `if let Some(routing_info_node) =
child.get_optional_child("routing_info")` and the `if let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref()` into a single `if let ... && let ...`
conditional so the match on `routing_info_node` and its `content` (the
`NodeContentRef::Bytes(routing_bytes)` variant) are done in one expression;
update the body that currently lives inside the inner block to run under that
single combined condition.

In `@src/handlers/notification.rs`:
- Around line 555-558: The closure in the filter_map is silently truncating
large key-index values by using `v as u32`; change the logic around the call to
`optional_u64("key-index")` in the filter_map so that you reject/return None for
values > u32::MAX instead of casting, e.g. attempt a checked conversion
(try_into or compare against u32::MAX) and only set `key_index` on
AccountSyncDevice when the u64 fits in a u32; keep the rest of the filter_map
branch behavior the same so oversized key-index attributes are filtered out
rather than wrapped.

In `@src/handlers/receipt.rs`:
- Around line 22-29: In handle (async fn handle) the call
client.handle_receipt(Arc::clone(&node)).await needlessly increments the Arc;
instead pass node by value to avoid the extra refcount bump — change the call to
client.handle_receipt(node).await (remove Arc::clone usage) so the OwnedNodeRef
Arc is moved into handle_receipt directly.

In `@src/message.rs`:
- Around line 73-74: Replace the direct pattern match against NodeContentRef in
plaintext_node by using the NodeRef content accessor: call
plaintext_node.content_bytes() and, if Some, pass the bytes to
wa::Message::decode instead of matching NodeContentRef::Bytes; update all
similar spots (the other occurrences around the indicated ranges) to use
content_bytes() so the code no longer depends on NodeContentRef internals.
- Around line 377-385: The code forces an owned allocation by calling
(*enc_node).to_owned() before dispatching to custom handlers, defeating
zero-copy; change the handler API used in handler_clone.handle to accept a
borrowed NodeRef (e.g., &NodeRef<'_> or &OwnedNodeRef) so you can pass enc_node
by reference directly instead of calling to_owned(), and only clone/allocate
inside specific handler implementations that need ownership; update the handler
trait/type signatures referenced by handler_clone.handle and all implementations
to accept the borrowed reference and adjust the call site to pass enc_node (or a
reference wrapper) without to_owned().

In `@src/pair_code.rs`:
- Around line 238-245: primary_wrapped_ephemeral is currently being allocated as
a Vec<u8> after you already validated the length==80; change the code to parse
the NodeContentRef::Bytes directly into a fixed-size [u8; 80] (e.g. using
try_into) instead of to_vec(), so you can borrow a &[u8; 80] or & [u8] when
calling decrypt_primary_ephemeral_pub; update the match arm that checks
reg_node.get_optional_child_by_tag(...) and produce a [u8;80] value (or an
Option<[u8;80]>) to avoid the heap allocation and pass that by reference into
decrypt_primary_ephemeral_pub.

In `@src/pair.rs`:
- Around line 198-206: The current code silently converts missing or invalid
jid/lid into default empty Jid via optional_jid(...).unwrap_or_default(), which
allows bad device identities to be persisted; change the logic in the
success_node/device parsing (the block using device_node.attrs() and
parser.optional_jid) to explicitly validate both "jid" and "lid" and reject the
pair-success stanza if either attribute is missing or malformed instead of
returning Jid::default(); i.e., stop using unwrap_or_default() for
parser.optional_jid, propagate the parsing error (or return an early
error/response) so SetId/SetLid never receive an empty/invalid Jid.
- Around line 163-168: The code clones the `device-identity` bytes into
`device_identity_bytes` before calling `do_pair_crypto`, breaking the zero-copy
path; instead, keep a borrowed slice and pass it directly to `do_pair_crypto`.
Replace the `...NodeContentRef::Bytes(b)) => Some(b.to_vec()),` branch so it
returns `Some(b)` or `Some(b.as_slice())` (i.e., an `&[u8]`) and change the
`device_identity_bytes` binding type to a borrowed slice (or Option<&[u8]>) so
`do_pair_crypto` receives `&[u8]` for its temporary use; if `do_pair_crypto`
currently requires ownership, update its signature to accept `&[u8]` so the
zero-copy path is preserved. Ensure lifetimes line up with the decoded buffer
scope and remove the unnecessary `to_vec()` call.
- Around line 53-57: The code currently does UTF-8 validation after allocating
by calling String::from_utf8(bytes.to_vec()); instead, keep the data borrowed
for validation using std::str::from_utf8(bytes) and only allocate at the end. In
the branch that matches NodeContentRef::Bytes(bytes) inside the
child.get_children_by_tag("ref") loop, replace String::from_utf8(bytes.to_vec())
with std::str::from_utf8(bytes) and then pass r.to_owned() (or r.to_string())
into PairUtils::make_qr_data(&device_state, ...) so validation happens on the
borrowed slice and allocation occurs only for the final String.

In `@src/retry.rs`:
- Line 223: handle_retry_receipt() now computes resolved_jid and passes it into
.process_retry_key_bundle(nr, &resolved_jid, is_peer) but
process_retry_key_bundle() still performs its own lookup and derives bundle
metadata from the pre-resolve parameter, reintroducing the redundant lookup and
ambiguous contract; update process_retry_key_bundle to accept and use the
already-resolved jid for all internal operations (remove the extra resolve call
and any metadata derivation that uses the original unresolved jid parameter),
ensure all places inside process_retry_key_bundle (and any helper it calls)
compute bundle metadata from the resolved_jid, and adjust callers (e.g.,
handle_retry_receipt and other callers in the 532-567 range) to pass the
resolved_jid consistently so no duplicate lookup occurs.

In `@src/test_utils.rs`:
- Around line 8-10: The code currently slices and clones the marshalled bytes
(bytes[1..].to_vec()), causing an extra allocation and a silent panic risk; in
node_to_owned_ref, assert the leading format byte explicitly (e.g.
assert_eq!(bytes.first(), Some(&EXPECTED_FORMAT_BYTE))) then remove it in-place
(bytes.remove(0)) and pass the modified bytes Vec into OwnedNodeRef::new so no
extra to_vec allocation or fragile slicing is used; refer to the marshal call
that produces bytes, the node_to_owned_ref helper, and OwnedNodeRef::new when
making this change.

In `@wacore/appstate/src/patch_decode.rs`:
- Around line 89-99: The wrappers parse_patch_list_ref and parse_patch_lists_ref
currently call node.to_owned(), cloning the NodeRef tree; remove those clones
and forward the reference directly by making the underlying parsers accept
references (or add reference-overloads) so you can call parse_patch_list(node)
and parse_patch_lists(node) without allocation; specifically eliminate the
node.to_owned() calls in parse_patch_list_ref and parse_patch_lists_ref and
update or add signatures for parse_patch_list and parse_patch_lists to take
&NodeRef<'_> (or provide parse_patch_list_ref_impl/parse_patch_lists_ref_impl
that accept &NodeRef<'_>) so the zero-copy path is preserved.

In `@wacore/binary/src/decoder.rs`:
- Around line 132-143: The match on `agent` in decoder.rs currently coerces
unknown domain-type bytes to `crate::jid::Server::Pn`; instead, detect
unsupported AD_JID bytes in the fallback arm of the `match agent { ... }` and
return a decoding error (aligning with WA Web behavior) instead of mapping to
PN. Replace the `_ => crate::jid::Server::Pn` arm with code that yields a
`Result::Err` (e.g. a `DecodeError::UnsupportedDomain(agent)` or similar error
type used by this decoder) so malformed/unknown domain bytes fail decoding
rather than silently becoming `Server::Pn`; keep the other arms (0,1,128,129,
hosted-bit rule) unchanged.

In `@wacore/binary/src/jid.rs`:
- Around line 508-512: The actual_agent() method currently zeros the agent only
for Server::Pn, causing mismatch with Display and is_ad() which treat
Server::Lid, Server::Hosted, and Server::HostedLid as agent-less; update
actual_agent() to return 0 for Server::Pn, Server::Lid, Server::Hosted, and
Server::HostedLid (and return self.agent for other variants) so string
round-trips preserve agent-less semantics consistent with Display and is_ad().

In `@wacore/binary/src/node.rs`:
- Around line 589-593: Update the doc comment on OwnedNodeRef to remove the
absolute claim of "zero allocation beyond the buffer itself" and instead state
that while OwnedNodeRef owns the decompressed buffer and NodeRef borrows from it
(avoiding copying payload bytes/strings), NodeRef still allocates container
structures for attributes and child nodes; reference OwnedNodeRef and NodeRef so
callers understand the remaining allocation cost.
- Around line 663-679: Add a forwarding method content_as_string on the
OwnedNodeRef wrapper so callers don't need to call .get() for the lossy string
view; implement pub fn content_as_string(&self) -> Option<String> that simply
returns self.get().content_as_string(), analogous to the existing content_bytes,
content_str and content_nodes methods on OwnedNodeRef.

In `@wacore/derive/src/lib.rs`:
- Around line 185-194: The generated try_from_node_ref should create a single
attribute parser instance (e.g., let mut attrs = node.attrs();) and use that
shared parser to parse every field (calling attrs.optional_jid(`#attr_name`) /
attrs.optional_* / attrs.required_* into local variables like
parsed_<field_ident>), then call attrs.finish()? to propagate any deferred parse
errors before constructing and returning Self with those locals; update the
blocks handling AttrType::Jid (and the similar blocks around the other mentioned
range) to stop calling node.attrs() inline and instead use the shared attrs
parser and finish() as described.

In `@wacore/src/appstate_sync.rs`:
- Around line 98-105: The provided synchronous download callback type FDownload:
Fn(&wa::ExternalBlobReference) -> Result<Vec<u8>> allows callers to perform
blocking I/O on async workers; change the API or offload calls: either
(preferred) make the callback async (e.g., FDownload:
Fn(&wa::ExternalBlobReference) -> impl Future<Output=Result<Vec<u8>>> + Send +
Sync) and update call sites (decode_patch_list_ref, and the other similar
functions around lines ~219-226, ~249-257, ~543-552) to await the download; or
keep a synchronous callback but ensure every invocation of download inside
decode_patch_list_ref (and the other named functions) wraps the call in
tokio::task::spawn_blocking and awaits the JoinHandle to prevent blocking the
Tokio runtime. Ensure trait bounds include Send + 'static where needed when
using spawn_blocking.

In `@wacore/src/iq/blocklist.rs`:
- Around line 116-131: Replace the hand-rolled traversal in parse_response with
a call to BlocklistResponse::try_from_node_ref to avoid duplicating parsing
logic: in GetBlocklistSpec::parse_response, call
BlocklistResponse::try_from_node_ref(response)?, extract/convert that
BlocklistResponse into the expected Self::Response (e.g. its entries or via an
existing conversion), and return it; remove the manual list/item iteration and
warn-on-entry-failure code so all parsing is centralized in BlocklistResponse.

In `@wacore/src/iq/business.rs`:
- Around line 55-59: In node_text, avoid cloning the bytes before UTF-8
validation: for the NodeContentRef::Bytes(b) arm, validate the borrowed slice
with std::str::from_utf8(b) (or equivalent) and then allocate the String once
(e.g., map the &str to owned via to_string()/to_owned()); update the
NodeContentRef::Bytes branch in fn node_text to return the String produced from
the validated slice instead of calling b.to_vec() and String::from_utf8.

In `@wacore/src/iq/chatstate.rs`:
- Around line 140-147: The parser currently uses attrs.optional_jid("from")
which collapses malformed and missing JIDs into None and never returns
ChatstateParseError::InvalidJid; change parse() to distinguish malformed vs
missing by using the fallible lookup (e.g., attrs.jid("from") or an equivalent
method that returns Result) so you can map a parse failure to
Err(ChatstateParseError::InvalidJid), keep the existing SelfEcho check when a
"to" attr exists, and return MissingFrom only when the "from" attribute is truly
absent; update the match around attrs.optional_jid("from") to first attempt a
fallible jid parse, return InvalidJid on parse error, return the parsed jid on
success, and preserve the SelfEcho / MissingFrom branches.

In `@wacore/src/iq/groups.rs`:
- Around line 379-382: The current code silently maps any unrecognized
participant type to ParticipantType::Member; change this to propagate unknown
values instead of downgrading: update the logic around
attrs.optional_string("type") and ParticipantType::try_from to either (a)
preserve the raw string when try_from fails (e.g., store Option<String> or a
ParticipantType::Unknown(String) variant) or (b) return/propagate an error up
the caller instead of using unwrap_or(Member). Locate the transformation that
assigns participant_type (the call chain
attrs.optional_string("type").and_then(|s|
ParticipantType::try_from(s.as_ref()).ok()).unwrap_or(ParticipantType::Member))
and replace it with code that forwards the unknown value or error so
admin/superadmin state isn't silently lost.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 303-323: The attribute parser is not finalized before constructing
MediaConnResponseExtended in try_from_node_ref, so malformed numeric/string
attrs (parsed via attrs.optional_u64 / attrs.optional_string) can be silently
accepted; after reading all attributes (auth, ttl, auth_ttl, max_buckets,
ip_token, set_ip_token) call the attr parser's finalization method (e.g.,
attrs.finish() or attrs.finalize() depending on the API) and propagate any error
before proceeding to build/return the MediaConnResponseExtended; ensure the
finalization happens before using
node.get_children_by_tag(...)/MediaConnHostExtended::try_from_node_ref so
attribute parse failures are not ignored.

In `@wacore/src/iq/node.rs`:
- Around line 22-25: The helper required_attr currently forces allocation by
converting the attribute to String; change its signature and implementation to
return a borrowed Cow<'_, str> so callers can avoid allocation — update
pub(crate) fn required_attr(node: &NodeRef<'_>, key: &str) -> Result<Cow<'_,
str>, anyhow::Error> and return the attribute as a Cow::Borrowed when possible
(only allocating to Cow::Owned when necessary), preserving the same error
behavior via anyhow! when missing; adjust any call-sites that expect String to
call .into_owned() where ownership is required.

In `@wacore/src/iq/tctoken.rs`:
- Around line 281-287: The code currently extracts the "jid" attr into a String
(jid_str) then reparses it into a Jid, causing an unnecessary allocation/parse;
instead use the attribute's typed conversion (ValueRef::to_jid / the .to_jid()
method) directly from token_node.get_attr("jid") to produce a Jid without
allocating a String—replace the jid_str retrieval and subsequent parse with a
direct call that maps missing attribute or conversion errors into the same
anyhow errors, referencing token_node.get_attr("jid"), the jid/Jid binding, and
the existing error messages for consistency.

In `@wacore/src/iq/usync.rs`:
- Around line 121-128: The parse_lid_jid function (and the other similar sites)
currently calls attrs().optional_string("val") and then parses that string into
a Jid, causing unnecessary allocation; change these to use
attrs().optional_jid("val") (or the ref-based optional_jid helper your attr API
provides) so the attribute is returned directly as a Jid without formatting and
reparsing—update parse_lid_jid and the other occurrences mentioned (the blocks
around the other similar helper functions) to replace
optional_string(...).and_then(|val| val.parse::<Jid>().ok()) with a single
attrs().optional_jid("val") call.

In `@wacore/src/media_retry.rs`:
- Around line 179-183: The code currently forces an allocation by calling
.into_owned() when extracting the "id" attribute; instead keep msg_id as a
borrowed &str by removing .into_owned() and using the &str directly for
comparisons in this zero-copy parse path. Update the local binding (msg_id) to
be a &str from .get_attr("id").map(|v| v.as_str()).ok_or_else(...)? and adjust
any later uses in the function (and its signature/closures if necessary) to
accept or compare against &str rather than String so no heap allocation occurs;
keep references to the variable name msg_id to locate and change the usage.

In `@wacore/src/pair_code.rs`:
- Around line 32-34: The code still imports and uses SERVER_JID (and calls
SERVER_JID.to_string()) in the pair-code IQ builders which preserves the old
string-based path; find all occurrences of SERVER_JID and any places that set
the "to" attribute via SERVER_JID.to_string() in wacore::pair_code IQ builder
functions and replace them with the new typed Server target (use the appropriate
Server value/instance your PR introduced instead of the string JID), remove the
SERVER_JID import, and ensure the builder signatures/usage serialize the Server
target correctly when emitting the IQ "to" attribute so pair-code follows the
typed Server migration.

In `@wacore/src/pair.rs`:
- Around line 85-98: build_ack_node_ref duplicates the ACK construction logic
from build_ack_node; extract a single helper (e.g., build_ack_node_from_parts or
build_ack_node_for) that takes (&str, &str) for (to, id) and returns
Option<Node> or Node, move the NodeBuilder call (attrs ("to", "id", "type" with
value "result") and building) into that helper, then change both
build_ack_node_ref (which should extract to and id via get_attr and map/as_str)
and build_ack_node to call this new helper so there is a single source of truth
for ACK construction.

In `@wacore/src/prekeys.rs`:
- Around line 144-147: The helper extract_bytes_ref currently allocates by
converting NodeContentRef::Bytes into a Vec<u8>; change it to return a borrowed
&[u8] (Result<&[u8], anyhow::Error>) so callers can perform length checks and
copy directly into fixed-size stack arrays at the final conversion point (e.g.,
where callers construct [u8; N] or call PreKeyBundle::new()). Update all call
sites that use extract_bytes_ref to accept a slice, perform the exact-length
check, and only then copy into the fixed-size array (or clone into Vec only when
PreKeyBundle::new truly requires owned data), eliminating the intermediate heap
allocation.

In `@wacore/src/stanza/devices.rs`:
- Around line 317-321: The code currently defaults stanza_id to an empty string
when node.get_attr("id") is missing; change this to require the id attribute
instead: in the function handling device stanzas (where stanza_id is constructed
from node.get_attr("id")), detect if get_attr("id") returns None and handle it
as an error/invalid stanza (e.g., log and return/skip processing or propagate an
Err) rather than unwrap_or_default(); ensure any downstream uses of stanza_id
assume a valid non-empty id so malformed device notifications are rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ec9c7138-fa5c-4603-9996-a2e604c332af

📥 Commits

Reviewing files that changed from the base of the PR and between 5a19d4b and 178a9b0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (120)
  • .gitignore
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/benches/binary_benchmark.rs
  • wacore/binary/src/attrs.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs
  • wacore/tests/binary_protocol_test.rs

Comment thread src/client.rs
Comment thread src/client.rs
Comment thread wacore/binary/src/jid.rs
return Ok(Jid {
user: CompactString::from(parts.user),
server: cow_server_from_str(parts.server),
server: Server::try_from(parts.server)?,

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The new numeric validation is still bypassed on the fast path.

The overflow check added here only runs when parse_jid_fast() returns None, but the fast parser still collapses malformed numeric parts into 0 instead of deferring to fallback validation. Inputs like 123:abc@s.whatsapp.net or user.300@hosted will still parse as different valid JIDs instead of erroring.

Also applies to: 679-683, 690-690

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` at line 627, The fast-path parser parse_jid_fast()
is currently collapsing malformed numeric parts to 0 so the new numeric
overflow/format validation never runs; modify parse_jid_fast() to detect and
reject malformed numeric components (e.g., non-digit characters or out-of-range
values) by returning None instead of silently converting them, so the code paths
that call Server::try_from(parts.server)? and the fallback validation logic will
run; also apply the same change to other fast-path uses referenced around the
numeric handling sites (the other parse_jid_fast call sites noted) so malformed
inputs like "123:abc@s.whatsapp.net" and "user.300@hosted" fall back to proper
validation and produce errors rather than turning into valid JIDs.

Comment thread wacore/src/appstate_sync.rs
Comment thread wacore/src/media_retry.rs
Comment on lines 208 to 211
let enc_iv = encrypt_node
.get_optional_child_by_tag(&["enc_iv"])
.and_then(get_bytes_content)
.and_then(get_bytes_content_ref)
.ok_or_else(|| anyhow!("missing enc_iv in encrypt node"))?;

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In the RustCrypto aes-gcmcrate, what happens ifNonce::from_slice(iv) receives a slice with length different from 12 bytes?

💡 Result:

If Nonce::from_slice(iv) receives a slice with length different from 12 bytes (for the standard Aes128Gcm or Aes256Gcm types, where Nonce = GenericArray<u8, U12>), it panics at runtime with an assertion failure due to length mismatch. Nonce is a type alias for GenericArray<u8, NonceSize>, and by default NonceSize = typenum::U12 (12 bytes / 96 bits), as AES-GCM recommends 96-bit nonces. The from_slice method on GenericArray<T, N> explicitly checks if the slice length equals N::to_usize (12) and panics if not, with a message like 'assertion failed: (left == right) left: <actual_length>, right: 12'. This behavior is documented in examples for similar Key::from_slice, which "panics on length mismatch", and confirmed in real-world usage errors (e.g., panics on 8 vs 12 bytes, or wrong key lengths). For non-standard nonce sizes (e.g., AesGcm<..., U16>), the required length changes accordingly, but from_slice still panics on mismatch.

Citations:


🏁 Script executed:

# First, locate and examine the media_retry.rs file
find . -name "media_retry.rs" -type f

Repository: jlucaso1/whatsapp-rust

Length of output: 93


🏁 Script executed:

# Check the code around lines 208-211 and surrounding context
head -220 wacore/src/media_retry.rs | tail -30

Repository: jlucaso1/whatsapp-rust

Length of output: 1159


🏁 Script executed:

# Search for ENC_IV_SIZE definition
rg "ENC_IV_SIZE" wacore/src/media_retry.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 177


🏁 Script executed:

# Search for Nonce::from_slice usage in media_retry.rs
rg "Nonce::from_slice" wacore/src/media_retry.rs -A 2 -B 2

Repository: jlucaso1/whatsapp-rust

Length of output: 380


🏁 Script executed:

# Find the decrypt_media_retry_notification function and see how it uses the iv parameter
rg "decrypt_media_retry_notification" wacore/src/media_retry.rs -A 15

Repository: jlucaso1/whatsapp-rust

Length of output: 2170


Add guard for enc_iv length in decrypt_media_retry_notification() to prevent panic on malformed input.

The enc_iv parameter comes from untrusted XML node bytes without validation. Passing a slice with length other than 12 bytes to Nonce::from_slice(iv) will panic at runtime (the RustCrypto aes-gcm crate's GenericArray::from_slice() asserts length equality and panics on mismatch). This creates a DoS vector.

Add the length check at the function entry as shown in the suggested diff to convert the panic into a proper error.

🛡️ Suggested diff (centralized guard in decrypt function)
 pub fn decrypt_media_retry_notification(
     media_key: &[u8],
     stanza_id: &str,
     iv: &[u8],
     ciphertext: &[u8],
 ) -> Result<wa::MediaRetryNotification> {
+    if iv.len() != ENC_IV_SIZE {
+        return Err(anyhow!(
+            "invalid enc_iv length: expected {}, got {}",
+            ENC_IV_SIZE,
+            iv.len()
+        ));
+    }
     let key = derive_media_retry_key(media_key)?;
     let cipher =
         Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("AES-GCM key init failed: {e}"))?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let enc_iv = encrypt_node
.get_optional_child_by_tag(&["enc_iv"])
.and_then(get_bytes_content)
.and_then(get_bytes_content_ref)
.ok_or_else(|| anyhow!("missing enc_iv in encrypt node"))?;
pub fn decrypt_media_retry_notification(
media_key: &[u8],
stanza_id: &str,
iv: &[u8],
ciphertext: &[u8],
) -> Result<wa::MediaRetryNotification> {
if iv.len() != ENC_IV_SIZE {
return Err(anyhow!(
"invalid enc_iv length: expected {}, got {}",
ENC_IV_SIZE,
iv.len()
));
}
let key = derive_media_retry_key(media_key)?;
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("AES-GCM key init failed: {e}"))?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 208 - 211, In
decrypt_media_retry_notification(), validate the enc_iv bytes before calling
Nonce::from_slice to avoid a panic: after obtaining enc_iv from the encrypt_node
(the enc_iv variable) check that enc_iv.len() == 12 and return an
Err(anyhow!("invalid enc_iv length: {} (expected 12)", enc_iv.len())) (or
similar error) if not; this turns the panic into a handled error path and
prevents malformed input from causing a crash.

Comment thread wacore/src/prekeys.rs

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/features/groups.rs (1)

422-427: 🛠️ Refactor suggestion | 🟠 Major

Update AcceptGroupInviteV4Iq::new to follow the IqSpec pattern by accepting references instead of owned values.

The constructor currently takes group_jid: Jid, code: String, admin_jid: Jid, requiring clones at the call site. Per coding guidelines, IqSpec constructors should take &Jid not Jid. Update the constructor signature to accept &Jid and &str, then remove the clones at the call site.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/groups.rs` around lines 422 - 427, AcceptGroupInviteV4Iq::new
currently takes owned values (group_jid: Jid, code: String, admin_jid: Jid)
forcing clones at call sites; change the constructor to follow the IqSpec
pattern by accepting borrowed types (e.g., new(group_jid: &Jid, code: &str,
expiration: ..., admin_jid: &Jid)), update its internal implementation to clone
only when it must own data, and then remove the .clone() and .to_string() calls
at callers like the execute(...) invocation shown (pass &group_jid and
code.as_str() or &code, and &admin_jid). Ensure you update all other call sites
of AcceptGroupInviteV4Iq::new to pass references and adjust any trait bounds or
lifetimes required by the IqSpec changes.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Do not silently drop malformed <host> entries during media_conn parsing.

Line 377-Line 379 uses .ok()? inside filter_map, which discards host parse failures and returns partial data. This should propagate an error so corrupt server responses are explicit.

🔧 Proposed fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(|host_node| {
+                let ext = MediaConnHostExtended::try_from_node_ref(host_node)?;
+                Ok(MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<Vec<_>, anyhow::Error>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The current parsing of
hosts silently drops malformed <host> entries by using .ok()? inside the
filter_map when calling MediaConnHostExtended::try_from_node_ref; instead
convert the iterator into a Result-producing flow and propagate parse errors
upward (e.g., map each host_node to
MediaConnHostExtended::try_from_node_ref(...).map(|ext| MediaConnHost { ... })
and collect::<Result<Vec<MediaConnHost>, _>>() or use try_fold) so that failures
in MediaConnHostExtended::try_from_node_ref are returned as Err from the
media_conn parsing code rather than being filtered out; adjust the surrounding
function signature to return a Result if needed.
wacore/src/iq/prekeys.rs (1)

53-74: ⚠️ Potential issue | 🟠 Major

Don't treat malformed digest fields as zero or empty.

extract_content_uint() and extract_content_bytes() currently collapse missing/non-byte/short payloads into 0 or Vec::new(). That means malformed <registration>, <skey>, or <list><id> data can still parse as a seemingly valid bundle instead of failing fast, which is risky for key-bundle handling. Make the required fixed-width fields return an error when the child is missing, non-bytes, or the wrong length.

Also applies to: 246-289

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/prekeys.rs` around lines 53 - 74, extract_content_uint and
extract_content_bytes currently swallow missing/non-bytes/short payloads into 0
or empty Vec, letting malformed fixed-width digest fields silently succeed;
change these helpers to return a Result (or Option) that fails when the node is
missing, content is not NodeContentRef::Bytes, or the bytes length is incorrect
for fixed-width fields (for extract_content_uint require exactly 4 bytes; for
fixed-width digests require their specific length), and propagate/handle those
errors in the callers (including the other occurrence around the 246-289 region)
so parsing returns an error instead of producing zero/empty values for malformed
registration/skey/list/id data.
src/handlers/ib.rs (1)

123-133: ⚠️ Potential issue | 🟠 Major

Persist edge routing via DeviceCommand, not modify_device.

This write path still mutates Device directly inside the spawned task, which bypasses the command/snapshot flow the repo standardizes on.

As per coding guidelines, "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 123 - 133, The spawned task is mutating
Device via client_clone.persistence_manager.modify_device(...) which bypasses
the command/snapshot flow; replace this with sending a DeviceCommand to the
persistence manager (e.g., construct a DeviceCommand::SetEdgeRouting {
routing_bytes: routing_bytes.clone() } or an appropriately named variant) and
call client_clone.persistence_manager.process_command(command).await instead of
modify_device; ensure you create/extend the DeviceCommand enum and handler to
set device.edge_routing_info, and use get_device_snapshot() elsewhere when reads
are needed.
src/message.rs (1)

138-152: ⚠️ Potential issue | 🟠 Major

Serialize retry-count updates before sending retries.

This getinsert sequence still races when multiple detached retry tasks hit the same message at once, so two failures can observe the same count and both send a retry/PDO even after the cap should have been reached. Please guard this with a per-message lock or an atomic cache update instead of relying on best-effort ordering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 138 - 152, The get→insert race in
increment_retry_count allows concurrent tasks to increment the same cache_key
past MAX_DECRYPT_RETRIES; replace the read-then-write with a serialized/atomic
update: either use a per-key async lock (e.g., maintain a message_retry_locks
map and acquire the lock for cache_key inside increment_retry_count before
reading/updating message_retry_counts) or change message_retry_counts to store
an AtomicU8 and perform an atomic fetch_update/fetch_add with a cap at
MAX_DECRYPT_RETRIES; remove the separate self.message_retry_counts.get(...) /
insert(...) steps and return the new count or None based on the atomic/locked
result.
wacore/binary/src/jid.rs (1)

82-96: ⚠️ Potential issue | 🟡 Minor

Fast path silently accepts malformed device components.

parse_jid_fast() uses unwrap_or(0) for numeric parsing (lines 68, 88, 101, 128), which silently converts malformed inputs like "123:abc@s.whatsapp.net" to valid JIDs with device=0. This bypasses the validation added in the fallback path.

Consider returning None from parse_jid_fast() when numeric parsing fails, allowing the fallback path to properly validate and reject malformed JIDs:

🔧 Suggested fix for one instance
         if let Some(pos) = colon_pos {
             let user_end = pos;
             let device_start = pos + 1;
             let device_slice = &s[device_start..at];
-            let device = device_slice.parse::<u16>().unwrap_or(0);
+            let device = device_slice.parse::<u16>().ok()?;
             return Some(ParsedJidParts {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 82 - 96, The fast-path in
parse_jid_fast currently uses unwrap_or(0) when parsing numeric components
(device/agent/integrator) which silently accepts malformed values; update
parse_jid_fast so that each numeric parse (the places calling
parse::<u16>().unwrap_or(0)) returns None on parse failure instead of defaulting
to 0, so malformed inputs like "123:abc@s.whatsapp.net" fall back to the full
parser or be rejected; locate the numeric parses in parse_jid_fast and change
their handling to propagate a None when parse::<u16>() fails, ensuring
ParsedJidParts is only constructed when all numeric components parsed
successfully.
src/retry.rs (1)

43-75: 🧹 Nitpick | 🔵 Trivial

Consider a macro or generic to reduce duplication between Node and NodeRef helpers.

extract_registration_id_from_node and extract_registration_id_from_node_ref have identical logic with only the type differing. While the test-only version may be acceptable, if both are long-term maintenance items, a small macro could unify them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 43 - 75, The two functions
extract_registration_id_from_node and extract_registration_id_from_node_ref
duplicate logic; refactor by extracting the shared logic into a generic helper
(or small macro) that abstracts over the node type and the byte-extraction
function — for example create a generic function like
extract_registration_id<T>(node: &T, get_bytes: fn(&T) -> Option<&[u8]>) or a
macro that calls the appropriate accessor, then have
extract_registration_id_from_node call that helper with Node and
get_bytes_content and extract_registration_id_from_node_ref call it with NodeRef
and get_bytes_content_ref, preserving the same 4-byte big-endian assembly and
return type.
src/client.rs (1)

4505-4508: 🛠️ Refactor suggestion | 🟠 Major

Stop mutating Device directly in tests.

These setups bypass PersistenceManager::process_command() and make the tests rely on a state transition production code is explicitly not supposed to perform. Seed the PN through the command path instead, then read it back via the snapshot APIs.

As per coding guidelines, "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()"

Also applies to: 4587-4590

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4505 - 4508, Tests are mutating Device via
pm.modify_device(...) which bypasses the intended path; instead build and send
the appropriate DeviceCommand that seeds the PN (e.g., a command that sets the
device's pn) to PersistenceManager::process_command(...) and then verify via
pm.get_device_snapshot(...) (replace pm.modify_device usage and similar
occurrences at the other spot with a DeviceCommand + pm.process_command(...)
call and assertions against get_device_snapshot()).
♻️ Duplicate comments (25)
wacore/appstate/src/patch_decode.rs (1)

89-99: ⚠️ Potential issue | 🟠 Major

*_ref APIs still force full clone and negate zero-copy.

Line 91 and Line 98 call to_owned(), so the new reference entry points allocate the entire node tree before parsing. That defeats the zero-copy migration goal for appstate decode paths.

♻️ Proposed fix (parse directly from &NodeRef)
 pub fn parse_patch_list_ref(node: &NodeRef<'_>) -> Result<PatchList> {
-    parse_patch_list(&node.to_owned())
+    let collection = node
+        .get_optional_child_by_tag(&["sync", "collection"])
+        .ok_or_else(|| anyhow!("missing sync/collection"))?;
+    parse_single_collection_ref(collection)
 }

 pub fn parse_patch_lists_ref(node: &NodeRef<'_>) -> Result<Vec<PatchList>> {
-    parse_patch_lists(&node.to_owned())
+    let sync_node = if node.tag == "sync" {
+        node
+    } else {
+        node.get_optional_child("sync")
+            .ok_or_else(|| anyhow!("missing sync node in response"))?
+    };
+
+    let Some(children) = sync_node.children() else {
+        return Ok(Vec::new());
+    };
+
+    children
+        .iter()
+        .filter(|c| c.tag == "collection")
+        .map(parse_single_collection_ref)
+        .collect()
 }
+
+fn parse_single_collection_ref(collection: &NodeRef<'_>) -> Result<PatchList> {
+    // Mirror parse_single_collection using NodeRef accessors to avoid ownership conversion.
+    // (same attribute/content parsing logic as Node variant)
+}
#!/bin/bash
set -euo pipefail

echo "Verify whether NodeRef entry points still clone:"
rg -n "parse_patch_list_ref|parse_patch_lists_ref|to_owned\\(" wacore/appstate/src/patch_decode.rs -n -C 3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/appstate/src/patch_decode.rs` around lines 89 - 99, The two zero-copy
entry points parse_patch_list_ref and parse_patch_lists_ref currently call
to_owned() on the NodeRef which forces a full clone and defeats zero-copy;
change these functions to call the existing parse_patch_list and
parse_patch_lists implementations directly using the &NodeRef (i.e., pass node
by reference or adjust those parsing helpers to accept &NodeRef if needed) so no
to_owned() allocation occurs—locate parse_patch_list_ref, parse_patch_lists_ref
and remove the to_owned() calls, ensuring types/signatures align with
parse_patch_list/parse_patch_lists to accept a NodeRef borrow.
src/history_sync.rs (1)

341-341: 🧹 Nitpick | 🔵 Trivial

Drop redundant type annotation on jid.

This can be inferred from parse() usage and downstream method calls; removing it trims noise.

♻️ Suggested cleanup
-        let jid: wacore_binary::Jid = match conv.id.parse() {
+        let jid = match conv.id.parse() {
             Ok(j) => j,
             Err(_) => return,
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/history_sync.rs` at line 341, The local variable declaration uses an
explicit type for jid (let jid: wacore_binary::Jid = match conv.id.parse() { ...
}) which is redundant; remove the type annotation and let Rust infer it by
changing the binding to let jid = match conv.id.parse() { ... } (keep the
existing match arms and error handling intact) so identifiers conv.id.parse()
and jid are used without the unnecessary type noise.
src/test_utils.rs (1)

8-13: ⚠️ Potential issue | 🟡 Minor

Add explicit guard for the leading format byte before remove(0).

Line 12 assumes the buffer is non-empty and correctly prefixed; without an assertion this can panic or strip valid payload if upstream behavior changes.

Proposed fix
 pub fn node_to_owned_ref(node: &Node) -> Arc<OwnedNodeRef> {
-    let bytes = wacore_binary::marshal::marshal(node).expect("marshal should succeed");
+    let mut bytes = wacore_binary::marshal::marshal(node).expect("marshal should succeed");
     // marshal() prepends a leading format byte; OwnedNodeRef::new expects raw protocol bytes
-    {
-        let mut bytes = bytes;
-        bytes.remove(0);
-        Arc::new(OwnedNodeRef::new(bytes).expect("OwnedNodeRef::new should succeed"))
-    }
+    assert!(!bytes.is_empty(), "marshal returned empty payload");
+    assert_eq!(bytes[0], 0x00, "unexpected marshal format byte");
+    bytes.remove(0);
+    Arc::new(OwnedNodeRef::new(bytes).expect("OwnedNodeRef::new should succeed"))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test_utils.rs` around lines 8 - 13, The code currently calls remove(0) on
the buffer returned by wacore_binary::marshal::marshal(node) before constructing
an OwnedNodeRef, which can panic or strip data if the buffer is empty or the
leading format byte is different; add an explicit guard that first asserts the
buffer is non-empty and then verifies the leading format byte equals the
expected marshal format marker (use the marshal module's constant or documented
value) before calling remove(0), and return or fail with a clear error message
if the checks fail so OwnedNodeRef::new is only called with validated raw
protocol bytes.
wacore/src/pair.rs (1)

85-98: 🛠️ Refactor suggestion | 🟠 Major

Consolidate duplicated ACK-node construction to one helper.

build_ack_node_ref duplicates the same IQ assembly logic used by build_ack_node, which makes future ACK shape changes easy to miss in one path.

♻️ Suggested refactor
 impl PairUtils {
+    fn build_ack_inner(to: &str, id: &str) -> Node {
+        NodeBuilder::new("iq")
+            .attrs([
+                ("to", to.to_string()),
+                ("id", id.to_string()),
+                ("type", "result".to_string()),
+            ])
+            .build()
+    }
+
     pub fn build_ack_node(request_node: &Node) -> Option<Node> {
         if let (Some(to), Some(id)) = (request_node.attrs.get("from"), request_node.attrs.get("id"))
         {
-            Some(
-                NodeBuilder::new("iq")
-                    .attrs([
-                        ("to", to.to_string()),
-                        ("id", id.to_string()),
-                        ("type", "result".to_string()),
-                    ])
-                    .build(),
-            )
+            Some(Self::build_ack_inner(to, id))
         } else {
             None
         }
     }

     pub fn build_ack_node_ref(request_node: &NodeRef<'_>) -> Option<Node> {
         let to = request_node.get_attr("from").map(|v| v.as_str())?;
         let id = request_node.get_attr("id").map(|v| v.as_str())?;
-        Some(
-            NodeBuilder::new("iq")
-                .attrs([
-                    ("to", to.to_string()),
-                    ("id", id.to_string()),
-                    ("type", "result".to_string()),
-                ])
-                .build(),
-        )
+        Some(Self::build_ack_inner(to, id))
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/pair.rs` around lines 85 - 98, build_ack_node_ref duplicates the
IQ assembly done in build_ack_node; update build_ack_node_ref to reuse the
single canonical ACK builder to avoid divergence — either call the existing
build_ack_node (passing the extracted "to" and "id" or a Node/attrs wrapper) or
extract the shared construction logic into a new helper used by both
build_ack_node and build_ack_node_ref; ensure the function still accepts a
NodeRef<'_> and returns Option<Node>, pulling "from" and "id" the same way but
delegating Node creation to the shared helper (refer to build_ack_node_ref,
build_ack_node, and NodeBuilder to locate the code).
wacore/src/iq/mediaconn.rs (1)

303-318: ⚠️ Potential issue | 🟠 Major

Attribute parse errors are still not finalized before constructing response objects.

Line 313-Line 318 and Line 369-Line 372 read numeric attrs via optional_u64(...) but never finalize parser errors. Malformed numeric attributes can be silently treated as 0/None instead of failing fast. This mirrors an earlier unresolved finding.

🐛 Minimal fix shape
         let set_ip_token = attrs.optional_u64("set_ip_token");
+        attrs.finish()?;

Apply the same pattern in MediaConnSpec::parse_response after reading ttl/auth_ttl/max_buckets.

Verification script:

#!/bin/bash
set -euo pipefail

# Inspect mediaconn parsing paths
sed -n '300,335p' wacore/src/iq/mediaconn.rs
sed -n '355,385p' wacore/src/iq/mediaconn.rs

# Inspect parser API contract
rg -n "fn optional_u64|fn finish" wacore/binary/src -A6 -B6

Also applies to: 359-372

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 303 - 318, The numeric attribute
reads in try_from_node_ref (ttl, auth_ttl, max_buckets, set_ip_token) and in
MediaConnSpec::parse_response must finalize parser errors instead of silently
treating malformed numbers as 0/None; after you call attrs.optional_u64(...) for
those fields, call the parser's finish() (or otherwise propagate the parser
error) to surface malformed-attribute errors (mirror the existing pattern used
elsewhere), e.g. read the optional_u64 fields and then invoke attrs.finish()? to
return any parse error instead of continuing.
wacore/derive/src/lib.rs (1)

185-194: ⚠️ Potential issue | 🟠 Major

Propagate attribute parser errors in generated try_from_node_ref.

Line 185-Line 194 still generate JID parsing via node.attrs().optional_jid(...) and Line 329-Line 335 build Self directly. If parser errors are finalized via finish(), malformed JID attrs can be masked as None/“missing required attribute” instead of surfacing the real parse failure. This appears to be the same unresolved issue raised earlier.

🔧 Suggested direction
- Ok(Self {
-     #(`#field_parsers`),*
- })
+ let mut attrs = node.attrs();
+ let parsed = Self {
+     #(`#field_parsers`),*
+ };
+ attrs.finish()?;
+ Ok(parsed)

And generate field parsers against attrs (single parser instance) instead of repeated node.attrs() calls.

Use this to verify parser semantics and current macro output:

#!/bin/bash
set -euo pipefail

# Inspect attribute parser API and whether errors are finalized in finish()
rg -n "fn optional_jid|fn optional_u64|fn finish" wacore/binary/src -A6 -B6

# Inspect derive code paths in question
sed -n '180,215p' wacore/derive/src/lib.rs
sed -n '320,340p' wacore/derive/src/lib.rs

Also applies to: 329-335

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/derive/src/lib.rs` around lines 185 - 194, The generated code
currently calls node.attrs().optional_jid(...) inline and then builds Self
directly, which can mask parser errors; change try_from_node_ref generation to
first create a single parser instance (e.g. let attrs = node.attrs();) and then
call attrs.jid(`#attr_name`)? for required JID fields and
attrs.optional_jid(`#attr_name`)? for optional JID fields so parsing errors
propagate via ? instead of being converted to None/missing; store each parsed
value in a local binding (e.g. let `#field_ident` = ...) and use those bindings
when constructing Self so all parser errors surface correctly (also apply same
pattern wherever AttrType::Jid is handled).
wacore/binary/src/decoder.rs (1)

111-115: ⚠️ Potential issue | 🟡 Minor

Add a regression test for the new invalid-server path.

This is the new fail-fast behavior behind the typed Server migration, but the decoder tests still never hit it. A tiny crafted buffer asserting Err(BinaryError::AttrParse(..)) here would keep the old Server::Pn fallback from creeping back in unnoticed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 111 - 115, Add a regression test
that exercises the new fail-fast invalid-server path in
decoder::read_value_as_string -> Server::try_from by crafting a small buffer
representing a JID_PAIR whose server string is invalid; the test should call the
decoder function that parses JID pairs (the code around read_value_as_string and
Server::try_from in decoder.rs) and assert that it returns
Err(BinaryError::AttrParse(_)) rather than falling back to Server::Pn. Ensure
the test constructs the exact binary sequence that triggers the server parse
branch and matches Err(BinaryError::AttrParse(..)) to prevent regressions.
src/client/device_registry.rs (1)

240-241: ⚠️ Potential issue | 🟠 Major

Don't cross-product aliases with both server variants.

resolve_lookup_keys() preserves which alias is LID vs PN, but delete_sessions_for_devices() still rebuilds every lookup key under both Server::Lid and Server::Pn. That creates invalid pairs like pn@lid / lid@pn and can delete the wrong Signal session. Match on UserLookupKeys and purge only the concrete (user, server) pairs you actually resolved; for Unknown, use the caller's real server. The new server argument passed at Line 240 still has no effect because clear_device_record() ignores it.

Also applies to: 301-307

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 240 - 241, The code is rebuilding
lookup keys under both Server::Lid and Server::Pn and thus can delete the wrong
sessions; update delete_sessions_for_devices()/clear_device_record() to respect
the original resolution from resolve_lookup_keys(): pattern-match on
UserLookupKeys (e.g., Concrete(Lid|Pn) vs Unknown) and only construct/purge the
actual (user, server) pairs you resolved — for Unknown use the server argument
passed by the caller instead of inventing the opposite variant — and ensure
clear_device_record() actually accepts and uses that server parameter rather
than ignoring it so only the intended Signal sessions are deleted.
wacore/src/media_retry.rs (2)

97-107: ⚠️ Potential issue | 🔴 Critical

Guard iv length before Nonce::from_slice().

iv comes from stanza bytes, and aes-gcm's Nonce::from_slice() panics on a non-12-byte slice. A malformed media-retry notification can therefore crash this path instead of returning an error.

🛡️ Suggested fix
 pub fn decrypt_media_retry_notification(
     media_key: &[u8],
     stanza_id: &str,
     iv: &[u8],
     ciphertext: &[u8],
 ) -> Result<wa::MediaRetryNotification> {
+    if iv.len() != ENC_IV_SIZE {
+        return Err(anyhow!(
+            "invalid enc_iv length: expected {}, got {}",
+            ENC_IV_SIZE,
+            iv.len()
+        ));
+    }
     let key = derive_media_retry_key(media_key)?;
     let cipher =
         Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("AES-GCM key init failed: {e}"))?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 97 - 107, The function
decrypt_media_retry_notification currently calls Nonce::from_slice(iv) which
will panic if iv is not 12 bytes; add a guard at the start of
decrypt_media_retry_notification to validate iv.len() == 12 and return an Err
(propagating an anyhow error) when it is malformed, before calling
Nonce::from_slice(), so malformed stanza bytes produce a proper error instead of
panicking.

178-183: 🧹 Nitpick | 🔵 Trivial

Keep msg_id borrowed in this NodeRef parse path.

into_owned() allocates even though the stanza id is only used as AAD and compared before returning. Keeping it borrowed preserves the zero-copy benefit of this migration.

♻️ Suggested change
     let msg_id = node
         .get_attr("id")
         .map(|v| v.as_str())
         .ok_or_else(|| anyhow!("notification missing 'id' attribute"))?
-        .into_owned();

@@
-    let notification = decrypt_media_retry_notification(media_key, &msg_id, enc_iv, enc_p)?;
+    let notification = decrypt_media_retry_notification(media_key, msg_id.as_ref(), enc_iv, enc_p)?;

@@
-    if let Some(ref returned_id) = notification.stanza_id
-        && returned_id != &msg_id
+    if let Some(ref returned_id) = notification.stanza_id
+        && returned_id != msg_id.as_ref()

Also applies to: 213-217

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 178 - 183, The code currently calls
into_owned() on the result of node.get_attr("id") (binding msg_id) which causes
an unnecessary allocation; instead keep msg_id as a borrowed &str (do not call
into_owned()), propagate that borrowed &str through the NodeRef parse path where
it's used for AAD and comparisons (ensure functions/structs that accept the id
take &str or a lifetime-parametrized borrow), and remove the allocation in both
occurrences (the one at msg_id and the duplicate later) so comparisons and AAD
use the zero-copy borrowed value.
wacore/src/iq/blocklist.rs (1)

116-131: 🧹 Nitpick | 🔵 Trivial

Reuse BlocklistResponse::try_from_node_ref instead of re-parsing here.

This reintroduces a second parser for the same wire format, so the next schema tweak can easily fix one path and leave the other stale.

♻️ Minimal cleanup
     fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
-        // BlocklistResponse checks for a <list> child or direct <item> children
-        let entries = if let Some(list) = response.get_optional_child("list") {
-            list.get_children_by_tag("item")
-        } else {
-            response.get_children_by_tag("item")
-        }
-        .filter_map(|item| match BlocklistEntry::try_from_node_ref(item) {
-            Ok(entry) => Some(entry),
-            Err(e) => {
-                warn!(target: "blocklist", "Failed to parse blocklist entry: {e}");
-                None
-            }
-        })
-        .collect();
-        Ok(entries)
+        Ok(BlocklistResponse::try_from_node_ref(response)?.entries)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/blocklist.rs` around lines 116 - 131, The parse_response
implementation is re-parsing the wire format; replace the manual item iteration
with a call to the existing BlocklistResponse::try_from_node_ref to produce the
same result and avoid duplicate parsing logic: inside parse_response, pass the
incoming response NodeRef to BlocklistResponse::try_from_node_ref (or the
appropriate associated try_from_node_ref for BlocklistResponse) and map/return
its parsed entries (or propagate its error) instead of using
BlocklistEntry::try_from_node_ref and the current filter_map/collect loop.
src/pair_code.rs (1)

238-245: 🧹 Nitpick | 🔵 Trivial

Use a fixed [u8; 80] for the wrapped ephemeral.

This branch already rejects every non-80-byte payload, so allocating a Vec here only adds heap traffic before the blocking decrypt.

♻️ Minimal cleanup
-    let primary_wrapped_ephemeral = match reg_node
+    let primary_wrapped_ephemeral: [u8; 80] = match reg_node
         .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
         .and_then(|n| match n.content.as_deref() {
-            Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
+            Some(NodeContentRef::Bytes(b)) => b.as_ref().try_into().ok(),
             _ => None,
         }) {
         Some(b) => b,
         None => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair_code.rs` around lines 238 - 245, The code allocates a Vec for the
80-byte wrapped ephemeral despite rejecting non-80 lengths; change the
extraction to produce a fixed [u8; 80] instead of Vec<u8> to avoid heap
allocation: in the match on
reg_node.get_optional_child_by_tag(...).and_then(...), when matching
Some(NodeContentRef::Bytes(b)) if b.len() == 80, copy b into a [u8; 80] (e.g.,
by initializing an array and copying the slice) and return that array (or wrap
it in the same owning type expected by primary_wrapped_ephemeral) so downstream
code that uses primary_wrapped_ephemeral works with a stack-allocated [u8; 80]
rather than Vec; update any bindings or function signatures that expect Vec<u8>
accordingly (refer to primary_wrapped_ephemeral,
reg_node.get_optional_child_by_tag, and NodeContentRef::Bytes).
wacore/src/iq/business.rs (1)

55-59: 🧹 Nitpick | 🔵 Trivial

Avoid re-allocating borrowed byte content in node_text.

This still clones every byte-backed payload before UTF-8 validation, which undercuts the zero-copy migration on a hot parsing helper.

♻️ Minimal cleanup
 fn node_text(node: &NodeRef<'_>) -> Option<String> {
     match node.content.as_deref() {
         Some(NodeContentRef::String(s)) => Some(s.to_string()),
-        Some(NodeContentRef::Bytes(b)) => String::from_utf8(b.to_vec()).ok(),
+        Some(NodeContentRef::Bytes(b)) => std::str::from_utf8(b).ok().map(str::to_owned),
         _ => None,
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/business.rs` around lines 55 - 59, The helper node_text
currently clones byte content via b.to_vec() before UTF-8 validation; replace
that with a zero-copy validation using std::str::from_utf8 on the borrowed bytes
and only allocate after validation. Specifically, in the match arm for
NodeContentRef::Bytes(b) inside fn node_text, change
String::from_utf8(b.to_vec()).ok() to std::str::from_utf8(b).ok().map(|s|
s.to_string()) so validation is done without cloning the byte slice first.
src/handlers/ib.rs (1)

113-116: 🛠️ Refactor suggestion | 🟠 Major

Collapse routing_info extraction into a let-chain.

This branch still reintroduces the nested if let pattern the repo is trying to avoid.

As per coding guidelines, "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 113 - 116, The nested extraction of
routing_info_node and routing_bytes should be collapsed into a let-chain to
avoid nested `if let` blocks: replace the two nested `if let` checks that call
child.get_optional_child("routing_info") and match
NodeContentRef::Bytes(routing_bytes) with a single `if let
Some(routing_info_node) = child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref()` pattern so the code in the branch uses
routing_bytes directly.
wacore/src/prekeys.rs (2)

137-139: 🧹 Nitpick | 🔵 Trivial

Drop the _ref suffix from the private parser helpers.

Now that &NodeRef is the only path here, the suffix just leaves migration noise in the internal API surface.

Also applies to: 211-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/prekeys.rs` around lines 137 - 139, Rename the private parser
helper node_to_pre_key_bundle_ref to node_to_pre_key_bundle (drop the _ref
suffix) and do the same for the other private parser helpers in this module that
carry the _ref suffix; update all internal call sites to use the new names, keep
their visibility and signatures unchanged, and run cargo build/tests to ensure
no references remain. Ensure you only change internal names (not public APIs)
and update any doc comments or uses inside the file so compilation succeeds.

144-147: 🧹 Nitpick | 🔵 Trivial

Keep prekey parsing borrowed through the fixed-size fields.

These helpers still to_vec() borrowed bytes and then immediately length-check/copy them into arrays, which gives back much of the zero-copy win on this parser.

Also applies to: 158-175, 241-257, 270-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/prekeys.rs` around lines 144 - 147, The helper extract_bytes_ref
currently clones borrowed bytes with to_vec(), losing zero-copy benefits; change
its signature to return a borrowed slice (Result<&[u8], anyhow::Error>) instead
of Vec<u8>, and propagate that pattern to the other similar helpers (those at
158-175, 241-257, 270-286). Update call sites to perform a length check on the
returned &[u8] and then copy into fixed-size arrays (e.g., validate len, then
array.copy_from_slice(slice) or use TryFrom<&[u8]> for arrays) rather than
allocating intermediate Vecs. Ensure lifetimes on NodeRef<'_> are preserved so
you never call to_vec() in these parsing helpers.
wacore/src/iq/usync.rs (1)

121-128: 🧹 Nitpick | 🔵 Trivial

Use optional_jid() on these JID-valued attrs.

These paths still stringify and re-parse JIDs, which adds avoidable allocations right on the new zero-copy parsing path.

Also applies to: 141-145, 315-326, 555-562

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/usync.rs` around lines 121 - 128, Replace the pattern that
stringifies then re-parses JIDs in parse_lid_jid: instead of calling
attrs().optional_string("val").and_then(|val| val.parse::<Jid>().ok()), use the
zero-copy helper attrs().optional_jid("val") so the Jid is returned directly;
apply the same change to the other occurrences in this file where
attrs().optional_string(...).and_then(|val| val.parse::<Jid>().ok()) is used
(same parsing sites referenced in the review).
wacore/src/stanza/devices.rs (1)

317-321: ⚠️ Potential issue | 🟠 Major

Reject device notifications that are missing id.

This still collapses a malformed stanza into stanza_id == "", which makes ACK/dedup handling ambiguous. Treat "id" as required and fail fast here instead.

Minimal fix
-        let stanza_id = node
-            .get_attr("id")
-            .map(|v| v.as_str())
-            .unwrap_or_default()
-            .into_owned();
+        let stanza_id = required_attr(node, "id")?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/stanza/devices.rs` around lines 317 - 321, The code currently
turns a missing "id" into an empty stanza_id; instead treat "id" as required by
replacing the unwrap_or_default flow for stanza_id with a fail-fast path: call
node.get_attr("id").map(|v| v.as_str().into_owned()).ok_or_else(|| /*
appropriate error or early-return */) and return or reject the stanza
immediately (e.g., return Err / continue / send a rejection) when the attribute
is absent. Update any callers/path (the logic around stanza_id) to handle the
error path accordingly so malformed device stanzas are not processed with
stanza_id == "".
src/message.rs (1)

376-383: 🧹 Nitpick | 🔵 Trivial

Custom enc handlers still force a full <enc> clone.

to_owned() rematerializes the entire encrypted node right on the hot path, so the zero-copy migration stops at the extension boundary. Since this PR is already carrying API breaks, it would be better to move custom handlers to &NodeRef/&OwnedNodeRef and clone only in implementations that truly need ownership.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 376 - 383, The code forces a full clone with
enc_node.to_owned() before calling handler_clone.handle, breaking the zero-copy
optimization; change the custom enc handler API to accept &NodeRef (or
&OwnedNodeRef) so you can pass enc_node by reference without rematerializing it
here (remove enc_node_owned and enc_type_owned usage), update the handler
trait/method signature (e.g. handle(&self, client: ..., node: &NodeRef, info:
&InfoArc) or handle(&self, client: ..., node: &OwnedNodeRef, ...)) and update
all custom handler implementations to clone only when they truly need ownership;
keep the runtime.spawn async move closure but capture references to
enc_node/enc_type instead of calling to_owned().
src/pair.rs (1)

195-203: ⚠️ Potential issue | 🟠 Major

Don’t default missing jid/lid during pairing success.

This still lets malformed pair-success stanzas fall through with Jid::default(), which then gets persisted via SetId/SetLid and leaves device state inconsistent. Reject the stanza instead of continuing with empty identifiers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 195 - 203, The code currently uses
parser.optional_jid(...).unwrap_or_default() when extracting jid and lid from
success_node, which lets malformed pair-success stanzas fall through with
Jid::default() and be persisted; change this to validate presence and reject the
stanza early: call parser.optional_jid("jid") and parser.optional_jid("lid") and
if either is None return an error/abort processing of the pair-success (do not
continue to the SetId/SetLid persistence path), or propagate a parsing error so
the stanza is rejected; locate this logic around
success_node.get_optional_child_by_tag(&["device"]) and replace the
unwrap_or_default usage with explicit presence checks that abort on missing
identifiers.
src/retry.rs (1)

566-567: ⚠️ Potential issue | 🟡 Minor

The duplicate resolve_encryption_jid() call remains.

Line 223 passes &resolved_jid (already resolved at line 194) to process_retry_key_bundle, but line 566 calls resolve_encryption_jid(requester_jid) again. The parameter is named requester_jid but receives the already-resolved JID, making this redundant.

🔧 Suggested fix
     async fn process_retry_key_bundle(
         &self,
         node: &NodeRef<'_>,
-        requester_jid: &wacore_binary::Jid,
+        resolved_jid: &wacore_binary::Jid,
         is_peer: bool,
     ) -> Result<(), anyhow::Error> {
         // ... keys extraction ...
 
-        let resolved_jid = self.resolve_encryption_jid(requester_jid).await;
         let signal_address = resolved_jid.to_protocol_address();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 566 - 567, The code calls resolve_encryption_jid()
twice: once to produce resolved_jid and again later with requester_jid (which is
already resolved), leading to redundant work and possible confusion; update the
call site that currently does resolve_encryption_jid(requester_jid) so it uses
the existing resolved_jid (or &resolved_jid) and its to_protocol_address() value
(signal_address) when calling process_retry_key_bundle and elsewhere, removing
the duplicate resolve_encryption_jid invocation and ensuring parameter names
match the already-resolved value.
wacore/binary/src/node.rs (2)

589-593: 🧹 Nitpick | 🔵 Trivial

The allocation claim in the doc comment is still overstated.

NodeRef allocates Vec for attrs and Box<NodeVec> for children. The "zero allocation beyond the buffer itself" claim misleads callers about the actual cost model.

📝 Suggested doc update
-/// A decoded node that owns its decompressed buffer. The inner `NodeRef`
-/// borrows directly from the buffer — zero allocation beyond the buffer itself.
+/// A decoded node that owns its decompressed buffer. The inner `NodeRef`
+/// borrows string and byte payloads directly from the buffer, avoiding copies
+/// of those values. Note that `NodeRef` still allocates containers (`Vec`/`Box`)
+/// for attributes and children.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/node.rs` around lines 589 - 593, The doc comment on
OwnedNodeRef overstates allocations — update the comment on struct OwnedNodeRef
to remove the phrase "zero allocation beyond the buffer itself" and explicitly
state that the inner NodeRef still allocates a Vec for attrs and a Box<NodeVec>
for children; reference the types NodeRef, attrs and NodeVec in the comment and
briefly describe the cheap sharing via Arc<OwnedNodeRef> while clarifying the
actual allocation costs.

663-679: 🧹 Nitpick | 🔵 Trivial

content_as_string() is still not forwarded on OwnedNodeRef.

NodeRef has content_as_string() (line 525), but OwnedNodeRef only forwards content_bytes(), content_str(), and content_nodes(). This gap forces callers to use .get() for string content extraction.

♻️ Add missing forwarding method
 impl OwnedNodeRef {
+    /// Extract text content, handling both String and Bytes (lossy UTF-8).
+    #[inline]
+    pub fn content_as_string(&self) -> Option<CompactString> {
+        self.get().content_as_string()
+    }
+
     /// Zero-copy byte content, if this node has Bytes content.
     #[inline]
     pub fn content_bytes(&self) -> Option<&[u8]> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/node.rs` around lines 663 - 679, OwnedNodeRef is missing a
forwarding method for NodeRef::content_as_string(), forcing callers to call
.get() manually; add a pub fn content_as_string(&self) -> Option<String>
(matching NodeRef's signature) to OwnedNodeRef that simply returns
self.get().content_as_string() so OwnedNodeRef mirrors NodeRef's API (refer to
OwnedNodeRef, NodeRef, content_as_string, and get()).
wacore/binary/src/jid.rs (1)

508-513: ⚠️ Potential issue | 🟠 Major

actual_agent() should return 0 for all agent-less servers (Pn, Lid, Hosted, HostedLid).

The method currently only matches Server::Pn but the codebase treats Lid, Hosted, and HostedLid as agent-less: the Display impl skips agent output for these servers (lines 714–719), and the encoder uses fixed domain_type values (1, 128, 129) instead of the agent field. Returning the agent field for these servers creates an inconsistency and potential for leaking hidden state if the method is used in future code.

Additionally, normalize_for_prekey_bundle() (lines 538–540) has the same gap—it only normalizes Pn | Lid but not Hosted | HostedLid.

Suggested fix
     pub fn actual_agent(&self) -> u8 {
         match self.server {
-            Server::Pn => 0,
+            Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid => 0,
             _ => self.agent,
         }
     }

Consider also updating normalize_for_prekey_bundle() to include Hosted | HostedLid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 508 - 513, The actual_agent() function
currently only zeroes the agent for Server::Pn; update its match to treat all
agent-less server variants (Server::Pn, Server::Lid, Server::Hosted,
Server::HostedLid) as returning 0 instead of self.agent, and likewise update
normalize_for_prekey_bundle() to include Server::Hosted and Server::HostedLid in
the branch that normalizes to agent 0 so behavior matches the Display and
encoder logic and prevents leaking hidden state.
src/client.rs (1)

4900-4902: 🧹 Nitpick | 🔵 Trivial

Drop the redundant node_to_owned_ref wrapper.

This shim only forwards to crate::test_utils::node_to_owned_ref, so it is just another indirection to keep in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4900 - 4902, Remove the redundant shim function
node_to_owned_ref that simply forwards to crate::test_utils::node_to_owned_ref:
delete the fn node_to_owned_ref(node: Node) -> Arc<wacore_binary::OwnedNodeRef>
{ crate::test_utils::node_to_owned_ref(&node) } definition and update any call
sites to call crate::test_utils::node_to_owned_ref(&node) directly (adjust
arguments if needed), ensuring imports/uses still compile after removal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9a7b265a-7131-4c02-b250-9865f3d2b4dc

📥 Commits

Reviewing files that changed from the base of the PR and between 178a9b0 and fafb553.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (120)
  • .gitignore
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/benches/binary_benchmark.rs
  • wacore/binary/src/attrs.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs
  • wacore/tests/binary_protocol_test.rs

client
.wait_for_event(15, |e| {
matches!(e, Event::Notification(node) if node.attrs.get("type").is_some_and(|v| v == "w:gp2"))
matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2"))

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider centralizing the w:gp2 notification predicate.

The same Event::Notification type check is duplicated several times; a shared helper would reduce drift risk in future node-API migrations.

Also applies to: 429-429, 551-551

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/memory_soak.rs` at line 409, The matched predicate checking
for the `w:gp2` notification is duplicated; create a single helper such as a
free function is_gp2_notification(event: &Event) -> bool (or an Event method
like Event::is_gp2_notification) that encapsulates matches!(e,
Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() ==
"w:gp2")), then replace all duplicate occurrences (e.g., the match at
tests/e2e/tests/memory_soak.rs using matches!(e, Event::Notification(...))) with
calls to that helper to centralize the logic.

.wait_for_event(10, |e| {
matches!(e, Event::Message(msg, _) if msg.conversation.is_some())
|| matches!(e, Event::Notification(node) if node.attrs.get("type").is_some_and(|v| v == "w:gp2"))
|| matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2"))

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional: reuse a shared group-notification matcher here too.

These inline w:gp2 checks duplicate logic already captured in test helpers; reusing one matcher would keep behavior consistent across e2e suites.

Also applies to: 436-436

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/offline_groups.rs` at line 147, The inline matcher checking
for Event::Notification(...).get_attr("type") == "w:gp2" duplicates existing
shared group-notification matcher; replace the inline closure (matches!(e,
Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() ==
"w:gp2"))) with the shared helper matcher (e.g., group_notification() or the
project's is_group_notification matcher) so tests reuse the common logic; update
both occurrences (around the current match and at the other occurrence) to call
that helper instead of repeating the attribute check.

Comment on lines +98 to +156
pub async fn decode_patch_list_ref<FDownload>(
&self,
stanza_root: &NodeRef<'_>,
download: FDownload,
validate_macs: bool,
) -> Result<(Vec<Mutation>, HashState, PatchList)>
where
FDownload: Fn(&wa::ExternalBlobReference) -> Result<Vec<u8>> + Send + Sync,
{
let mut pl = parse_patch_list_ref(stanza_root)?;

// Download external snapshot if present (matches WhatsApp Web behavior)
if pl.snapshot.is_none()
&& let Some(ext) = &pl.snapshot_ref
&& let Ok(data) = download(ext)
&& let Ok(snapshot) = wa::SyncdSnapshot::decode(data.as_slice())
{
pl.snapshot = Some(snapshot);
}

// Download external mutations for each patch (matches WhatsApp Web behavior)
for patch in &mut pl.patches {
if let Some(ext) = &patch.external_mutations {
let patch_version = patch.version.as_ref().and_then(|v| v.version).unwrap_or(0);
match download(ext) {
Ok(data) => match wa::SyncdMutations::decode(data.as_slice()) {
Ok(ext_mutations) => {
log::trace!(
target: "AppState",
"Downloaded external mutations for patch v{}: {} mutations (inline had {})",
patch_version,
ext_mutations.mutations.len(),
patch.mutations.len()
);
patch.mutations = ext_mutations.mutations;
}
Err(e) => {
log::warn!(
target: "AppState",
"Failed to decode external mutations for patch v{}: {}",
patch_version,
e
);
}
},
Err(e) => {
log::warn!(
target: "AppState",
"Failed to download external mutations for patch v{}: {}",
patch_version,
e
);
}
}
}
}

self.process_patch_list(pl, validate_macs).await
}

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Code duplication: decode_patch_list_ref and decode_patch_list have identical download/hydration logic.

Lines 108-153 duplicate lines 169-214 (snapshot download, external mutations loop). The multi-collection variants correctly use the shared process_patch_lists helper, but the single-collection variants don't benefit from this refactor.

Consider extracting a hydrate_patch_list(&mut pl, &download) helper that both decode_patch_list_ref and decode_patch_list call after parsing, to keep behavior aligned as this code evolves.

♻️ Sketch of shared hydration helper
fn hydrate_patch_list<FDownload>(
    pl: &mut PatchList,
    download: &FDownload,
) where
    FDownload: Fn(&wa::ExternalBlobReference) -> Result<Vec<u8>> + Send + Sync,
{
    // Download external snapshot if present
    if pl.snapshot.is_none()
        && let Some(ext) = &pl.snapshot_ref
        && let Ok(data) = download(ext)
        && let Ok(snapshot) = wa::SyncdSnapshot::decode(data.as_slice())
    {
        pl.snapshot = Some(snapshot);
    }
    
    // Download external mutations for each patch
    for patch in &mut pl.patches {
        if let Some(ext) = &patch.external_mutations {
            // ... existing logic ...
        }
    }
}

Then both entrypoints become:

let mut pl = parse_patch_list_ref(stanza_root)?;
hydrate_patch_list(&mut pl, &download);
self.process_patch_list(pl, validate_macs).await
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/appstate_sync.rs` around lines 98 - 156, The snapshot/mutations
download logic duplicated between decode_patch_list_ref and decode_patch_list
should be moved into a shared helper (e.g., fn hydrate_patch_list<FDownload>(pl:
&mut PatchList, download: &FDownload) where FDownload:
Fn(&wa::ExternalBlobReference) -> Result<Vec<u8>> + Send + Sync) that performs
the snapshot_ref decode and per-patch external_mutations download/decoding and
logging; call parse_patch_list_ref (and the single-collection parse helper in
decode_patch_list) to get a mutable PatchList, call hydrate_patch_list(&mut pl,
&download), then pass pl into self.process_patch_list(pl, validate_macs).await
so both entrypoints reuse identical hydration behavior.

Comment on lines +98 to +105
pub async fn decode_patch_list_ref<FDownload>(
&self,
stanza_root: &NodeRef<'_>,
download: FDownload,
validate_macs: bool,
) -> Result<(Vec<Mutation>, HashState, PatchList)>
where
FDownload: Fn(&wa::ExternalBlobReference) -> Result<Vec<u8>> + Send + Sync,

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

FDownload callback design permits blocking I/O on async threads — consider documenting expectation.

The FDownload: Fn(...) -> Result<Vec<u8>> signature is synchronous, but it's invoked directly from async methods without spawn_blocking. Current callers use pre-downloaded caches (safe), but the trait allows passing blocking implementations.

Since the past review flagged this and the current callers are safe, consider adding a doc comment clarifying the expectation:

/// # Note
/// The `download` callback must be non-blocking (e.g., a cache lookup).
/// Blocking I/O implementations will block the async runtime.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/appstate_sync.rs` around lines 98 - 105, The FDownload callback
for decode_patch_list_ref is synchronous (FDownload: Fn(...) -> Result<Vec<u8>>)
but used from async code, which permits callers to supply blocking I/O; add a
doc comment on the decode_patch_list_ref function clarifying that the download
callback MUST be non-blocking (e.g., cache lookup) and that blocking I/O will
block the async runtime, so callers should either provide a non-blocking
implementation or change to an async/spawn_blocking approach if they need
blocking I/O.


fn build_iq(&self) -> InfoQuery<'static> {
let mut iq = InfoQuery::get("w:p", Jid::new("", SERVER_JID), None);
let mut iq = InfoQuery::get("w:p", Jid::new("", Server::Pn), None);

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add an explicit assertion for keepalive to JID wire contract.

Given the switch to Jid::new("", Server::Pn) at Line 42, add a test assertion that the IQ target remains s.whatsapp.net to lock behavior.

Proposed test hardening
 #[test]
 fn test_keepalive_spec_build_iq() {
     let spec = KeepaliveSpec::new();
     let iq = spec.build_iq();

     assert_eq!(iq.namespace, "w:p");
     assert_eq!(iq.query_type, crate::request::InfoQueryType::Get);
+    assert_eq!(iq.to.to_string(), "s.whatsapp.net");
     assert!(iq.content.is_none());
     assert!(iq.timeout.is_none());
 }

Also applies to: 61-69

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/keepalive.rs` at line 42, The change uses Jid::new("",
Server::Pn) when building keepalive IQs (InfoQuery::get), but we must harden the
test to assert the resulting IQ "to" JID remains the WhatsApp server host
("s.whatsapp.net"). Add an explicit assertion in the keepalive tests (around the
code constructing InfoQuery::get and the related block at the other occurrence)
that inspects the produced IQ's to/to_string() or its Jid and compares it to the
expected s.whatsapp.net value, so InfoQuery::get with Jid::new("", Server::Pn)
always yields the correct target host.

Comment thread wacore/src/pair.rs
Comment on lines +420 to +421
#[serde(skip)]
Notification(Arc<OwnedNodeRef>),

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does Serde's #[serde(skip)] on an enum variant cause serializing that variant to return an error rather than serializing the variant with omitted fields?

💡 Result:

No, Serde's #[serde(skip)] on an enum variant causes attempting to serialize that variant to return an error, rather than serializing it with omitted fields. The official documentation states: "Never serialize or deserialize this variant." For serialization specifically, the generated code explicitly returns _serde::private::Err(_serde::ser::Error::custom("the enum variant {type}::{variant} cannot be serialized")) when that variant is encountered, as shown in serde_derive source code and confirmed in multiple GitHub issues discussing this behavior.

Citations:


#[serde(skip)] on enum variants causes serialization to fail with an error.

Applying #[serde(skip)] to Event::Notification (line 420) and Event::RawNode (line 473) makes serializing these variants return an error instead of skipping fields. Since Event derives Serialize, this breaks the serialization contract for callers attempting to serialize these variants. Use a custom serialization strategy if the intent is to keep Event: Serialize while excluding the Arc<OwnedNodeRef> data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 420 - 421, Remove the #[serde(skip)]
attribute from the Event enum variants and implement a custom Serialize for the
enum (impl Serialize for Event) that matches the derived variant naming but
omits/serializes placeholder data for the Notification and RawNode variants
(i.e., when matching Event::Notification(Arc<OwnedNodeRef>) and
Event::RawNode(Arc<OwnedNodeRef>) serialize them as unit variants or with
minimal marker fields instead of attempting to serialize the Arc<OwnedNodeRef>),
so Event can still implement Serialize while excluding the OwnedNodeRef payload.

Comment thread wacore/src/usync.rs
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from fafb553 to 4469c2e Compare April 12, 2026 02:28

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
wacore/src/message_processing.rs (1)

113-119: 🧹 Nitpick | 🔵 Trivial

Consider using consistent attribute access pattern.

Lines 114 and 168 use direct field access (enc_node.attrs.get(...)), while lines 106, 121, and 137 use the method call (enc_node.attrs().optional_*()). For consistency and to leverage the typed helper methods, consider refactoring lines 113-119:

♻️ Proposed refactor for consistency
-        if enc_node
-            .attrs
-            .get("decrypt-fail")
-            .is_some_and(|v| v == "hide")
-        {
-            has_hide_fail = true;
-        }
+        if enc_node
+            .attrs()
+            .optional_string("decrypt-fail")
+            .is_some_and(|s| s.as_ref() == "hide")
+        {
+            has_hide_fail = true;
+        }

Similarly, consider updating line 168 to use attrs() if a helper method is available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/message_processing.rs` around lines 113 - 119, Replace the direct
field access enc_node.attrs.get("decrypt-fail") used when setting has_hide_fail
with the same attrs() helper pattern used elsewhere (e.g.,
enc_node.attrs().optional_*()), e.g., call the typed helper used at lines
106/121/137 to fetch the "decrypt-fail" attribute and test for "hide"; also
update the similar access at line 168 to use attrs() for consistency so both
checks use the same helper methods rather than direct .attrs field access.
src/handlers/message.rs (1)

84-99: 🧹 Nitpick | 🔵 Trivial

Remove the per-message Box::pin allocation in the worker loop.

The future is awaited immediately, so Box::pin(...) only adds a heap allocation on this hot path. Await client.handle_incoming_message(msg_node) directly.

♻️ Minimal change
-                            Box::pin(client.handle_incoming_message(msg_node)).await;
+                            client.handle_incoming_message(msg_node).await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.rs` around lines 84 - 99, The worker loop currently
wraps the per-message future in Box::pin before awaiting, causing an unnecessary
heap allocation: inside the spawned async block that receives from rx (the while
let Ok(msg_node) = rx.recv().await loop) remove the Box::pin(...) wrapper and
simply await the future returned by
client_for_worker.clone().handle_incoming_message(msg_node) directly; keep the
surrounding logic (connection_generation check, timing with
wacore::time::now_millis) unchanged so only the Box::pin allocation is
eliminated.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Propagate malformed <host> nodes instead of dropping them.

This filter_map(...ok()?) turns host parse failures into missing hosts. A bad <host> stanza should fail the IQ parse, not silently shrink the retry list.

🐛 Suggested fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(|host_node| {
+                let ext = MediaConnHostExtended::try_from_node_ref(host_node)?;
+                Ok(MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<Vec<_>, anyhow::Error>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The code currently uses
filter_map(...ok()?) when parsing hosts which swallows parse errors from
MediaConnHostExtended::try_from_node_ref and silently drops malformed <host>
nodes; change the parsing to map each host_node to a Result<MediaConnHost, _> by
calling MediaConnHostExtended::try_from_node_ref(host_node).map(|ext|
MediaConnHost { hostname: ext.hostname, host_type: ext.host_type,
fallback_hostname: ext.fallback_hostname }) and then
collect::<Result<Vec<MediaConnHost>, _>>() (or otherwise propagate the first
error) so parse failures from MediaConnHostExtended bubble up from the enclosing
IQ parse function instead of being filtered out.
wacore/src/request.rs (1)

201-226: 🧹 Nitpick | 🔵 Trivial

Drop the boxed Result on the IQ parse hot path.

parse_iq_response() still allocates a Box for every response even though the function can return Result<(), IqError> directly. That undercuts the allocation reductions this PR is aiming for.

♻️ Suggested cleanup
-    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Box<Result<(), IqError>> {
+    pub fn parse_iq_response(&self, response_node: &NodeRef<'_>) -> Result<(), IqError> {
         if response_node.tag == "stream:error" || response_node.tag == "xmlstreamend" {
-            return Box::new(Err(IqError::Disconnected(response_node.to_owned())));
+            return Err(IqError::Disconnected(response_node.to_owned()));
         }

         if let Some(res_type) = response_node.get_attr("type")
             && res_type.as_str() == "error"
         {
             let error_child = response_node.get_optional_child_by_tag(&["error"]);
             if let Some(error_node) = error_child {
                 let mut parser = error_node.attrs();
                 let code = parser.optional_u64("code").unwrap_or(0) as u16;
                 let text = parser
                     .optional_string("text")
                     .as_deref()
                     .unwrap_or("")
                     .to_string();
-                return Box::new(Err(IqError::ServerError { code, text }));
+                return Err(IqError::ServerError { code, text });
             }
-            return Box::new(Err(IqError::ServerError {
+            return Err(IqError::ServerError {
                 code: 0,
                 text: "Malformed error response".to_string(),
-            }));
+            });
         }

-        Box::new(Ok(()))
+        Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/request.rs` around lines 201 - 226, The function parse_iq_response
currently returns Box<Result<(), IqError>> causing unnecessary heap allocation
on the hot path; change its signature to return Result<(), IqError> and remove
all Box::new(...) wrappers in its body so you return Err(IqError::...) or Ok(())
directly (preserve the same error cases: Disconnected(response_node.to_owned()),
ServerError { code, text }, and the malformed error case). After this change,
update any callers of parse_iq_response to handle a plain Result<(), IqError>
instead of a boxed Result.
src/retry.rs (1)

235-252: ⚠️ Potential issue | 🟠 Major

Ignore invalid registration ID 0 in the session-deletion fallback.

process_retry_key_bundle() already rejects registration_id == 0, but this fallback path still treats 0 as a mismatch and can delete an otherwise valid session after a malformed retry receipt.

🐛 Proposed fix
-                if let Some(received_reg_id) = extract_registration_id_from_node_ref(nr) {
+                if let Some(received_reg_id) = extract_registration_id_from_node_ref(nr)
+                    && received_reg_id != 0
+                {
                     let signal_address = resolved_jid.to_protocol_address();
                     let device_store = self.persistence_manager.get_device_arc().await;
                     let device_guard = device_store.read().await;
@@
-                    if let Some(session) = session
+                    if let Some(session) = session
                         && let Ok(stored_reg_id) = session.remote_registration_id()
                         && stored_reg_id != 0
                         && stored_reg_id != received_reg_id
                     {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 235 - 252, The fallback path that compares
received_reg_id to the session's stored_reg_id should ignore registration ID 0
(invalid) so we don't delete a valid session after a malformed retry; update the
condition in the block using extract_registration_id_from_node_ref,
signal_cache.get_session, and session.remote_registration_id() so it only treats
it as a mismatch when both stored_reg_id != 0 AND received_reg_id != 0 AND
stored_reg_id != received_reg_id (i.e., skip deletion if either id is 0).
src/client.rs (2)

4505-4508: 🛠️ Refactor suggestion | 🟠 Major

Use DeviceCommand for test setup too.

These setups bypass PersistenceManager::process_command() and mutate device state directly, so they no longer exercise the same state-transition path production uses. Please seed PN through DeviceCommand here as well. As per coding guidelines, "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

Also applies to: 4587-4590

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4505 - 4508, The test is mutating device state
directly via pm.modify_device(...) which bypasses
PersistenceManager::process_command(); instead construct the appropriate
DeviceCommand variant that sets the device PN (e.g., an UpdatePhoneNumber/SetPn
variant) and submit it to pm.process_command(...) to seed the PN, then read back
via pm.get_device_snapshot() to verify; replace the direct pm.modify_device(...)
calls at both locations with creating the DeviceCommand, awaiting
pm.process_command(command).await, and using get_device_snapshot() to assert the
PN.

2492-2555: 🧹 Nitpick | 🔵 Trivial

Avoid parsing the same app-state response twice.

Both sync paths first call parse_patch_list*_ref(resp.get()) to discover external blobs and then immediately call decode_*_ref(resp.get(), ...) on the same tree. On large startup sync payloads, that doubles traversal and attribute decoding on a latency-sensitive path. Consider a single decode API that also exposes the external blob refs, or thread the parsed list into the processor.

Also applies to: 2712-2772

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 2492 - 2555, The code currently calls
wacore::appstate::patch_decode::parse_patch_lists_ref(resp.get()) to discover
external blobs and then calls proc.decode_multi_patch_list_ref(resp.get(),
&download, true) which re-traverses and re-decodes the same app-state tree; to
fix, change flow so the parsed patch lists are produced once and reused: either
modify decode_multi_patch_list_ref (and related decode_*_ref APIs) to accept
parsed patch lists (or return external refs along with decode results), or
thread the parsed patch_lists into
get_app_state_processor()/decode_multi_patch_list_ref so the processor uses the
already-parsed structure instead of calling parse_patch_lists_ref again; update
the pre_downloaded/download logic to consume the single parsed patch_lists and
remove the duplicate parse of resp.get().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/iq/groups.rs`:
- Around line 590-601: The code is still extracting JIDs via string + parse
(attrs.optional_string(...).parse::<Jid>()), reintroducing extra work from the
NodeRef migration; replace those with the NodeRef/JID-aware accessors (e.g.,
call the attrs method that returns an Option<Jid> directly) for the creator and
subject_owner fields: replace the creator initialization and the subject_owner
initialization that use attrs.optional_string(...) and parse::<Jid>() with the
appropriate attrs.optional_jid("creator") and attrs.optional_jid("s_o") (or the
project’s equivalent JID-returning accessor) and remove the manual parse steps
so the variables remain Option<Jid>.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 4505-4508: The test is mutating device state directly via
pm.modify_device(...) which bypasses PersistenceManager::process_command();
instead construct the appropriate DeviceCommand variant that sets the device PN
(e.g., an UpdatePhoneNumber/SetPn variant) and submit it to
pm.process_command(...) to seed the PN, then read back via
pm.get_device_snapshot() to verify; replace the direct pm.modify_device(...)
calls at both locations with creating the DeviceCommand, awaiting
pm.process_command(command).await, and using get_device_snapshot() to assert the
PN.
- Around line 2492-2555: The code currently calls
wacore::appstate::patch_decode::parse_patch_lists_ref(resp.get()) to discover
external blobs and then calls proc.decode_multi_patch_list_ref(resp.get(),
&download, true) which re-traverses and re-decodes the same app-state tree; to
fix, change flow so the parsed patch lists are produced once and reused: either
modify decode_multi_patch_list_ref (and related decode_*_ref APIs) to accept
parsed patch lists (or return external refs along with decode results), or
thread the parsed patch_lists into
get_app_state_processor()/decode_multi_patch_list_ref so the processor uses the
already-parsed structure instead of calling parse_patch_lists_ref again; update
the pre_downloaded/download logic to consume the single parsed patch_lists and
remove the duplicate parse of resp.get().

In `@src/handlers/message.rs`:
- Around line 84-99: The worker loop currently wraps the per-message future in
Box::pin before awaiting, causing an unnecessary heap allocation: inside the
spawned async block that receives from rx (the while let Ok(msg_node) =
rx.recv().await loop) remove the Box::pin(...) wrapper and simply await the
future returned by client_for_worker.clone().handle_incoming_message(msg_node)
directly; keep the surrounding logic (connection_generation check, timing with
wacore::time::now_millis) unchanged so only the Box::pin allocation is
eliminated.

In `@src/retry.rs`:
- Around line 235-252: The fallback path that compares received_reg_id to the
session's stored_reg_id should ignore registration ID 0 (invalid) so we don't
delete a valid session after a malformed retry; update the condition in the
block using extract_registration_id_from_node_ref, signal_cache.get_session, and
session.remote_registration_id() so it only treats it as a mismatch when both
stored_reg_id != 0 AND received_reg_id != 0 AND stored_reg_id != received_reg_id
(i.e., skip deletion if either id is 0).

In `@wacore/src/iq/mediaconn.rs`:
- Around line 375-385: The code currently uses filter_map(...ok()?) when parsing
hosts which swallows parse errors from MediaConnHostExtended::try_from_node_ref
and silently drops malformed <host> nodes; change the parsing to map each
host_node to a Result<MediaConnHost, _> by calling
MediaConnHostExtended::try_from_node_ref(host_node).map(|ext| MediaConnHost {
hostname: ext.hostname, host_type: ext.host_type, fallback_hostname:
ext.fallback_hostname }) and then collect::<Result<Vec<MediaConnHost>, _>>() (or
otherwise propagate the first error) so parse failures from
MediaConnHostExtended bubble up from the enclosing IQ parse function instead of
being filtered out.

In `@wacore/src/message_processing.rs`:
- Around line 113-119: Replace the direct field access
enc_node.attrs.get("decrypt-fail") used when setting has_hide_fail with the same
attrs() helper pattern used elsewhere (e.g., enc_node.attrs().optional_*()),
e.g., call the typed helper used at lines 106/121/137 to fetch the
"decrypt-fail" attribute and test for "hide"; also update the similar access at
line 168 to use attrs() for consistency so both checks use the same helper
methods rather than direct .attrs field access.

In `@wacore/src/request.rs`:
- Around line 201-226: The function parse_iq_response currently returns
Box<Result<(), IqError>> causing unnecessary heap allocation on the hot path;
change its signature to return Result<(), IqError> and remove all Box::new(...)
wrappers in its body so you return Err(IqError::...) or Ok(()) directly
(preserve the same error cases: Disconnected(response_node.to_owned()),
ServerError { code, text }, and the malformed error case). After this change,
update any callers of parse_iq_response to handle a plain Result<(), IqError>
instead of a boxed Result.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 3638-3652: The ACK path in build_ack_node rebuilds JID-backed
attributes (from, participant, type) by calling get_attr(...).as_str() and
re-wrapping into NodeValue, defeating the zero-copy intent; change the code to
preserve and carry the original typed attribute values instead of converting to
strings—e.g., use the wacore_binary typed attribute accessor (the
typed/JID-returning get_attr variant or the raw attribute value) and construct
NodeValue directly from that typed value for the fields from, participant and
type so no as_str/as_ref reallocation occurs; update build_ack_node to use those
typed accessors and NodeValue constructors for those three attributes.
- Around line 4900-4902: The local shim function fn node_to_owned_ref(node:
Node) -> Arc<wacore_binary::OwnedNodeRef> simply forwards to
crate::test_utils::node_to_owned_ref and should be removed; instead add a use
crate::test_utils::node_to_owned_ref; (or a renamed import if a name conflict
exists) and update all local call sites to call the imported
crate::test_utils::node_to_owned_ref directly, then delete the wrapper function
node_to_owned_ref to avoid the redundant indirection.
- Around line 2299-2317: The code removes the waiter from self.response_waiters
before attempting the fallible marshal_ref/OwnedNodeRef::new conversion, so if
conversion fails the waiter is dropped instead of being fulfilled; change the
logic to materialize the ACK payload first (call
wacore_binary::marshal::marshal_ref and OwnedNodeRef::new) and only after
successful construction remove the waiter from response_waiters and send the
Arc<OwnedNodeRef>, or if materialization fails explicitly notify the waiter of
failure (e.g., send an error outcome) instead of just dropping it; locate the
logic around id_opt, response_waiters.lock().await.remove(&id), marshal_ref, and
OwnedNodeRef::new and reorder/adjust to keep the waiter alive until send or
explicit failure is performed.

In `@src/client/device_registry.rs`:
- Around line 301-313: The current delete_sessions_for_devices function
cross-products lookup.all_keys() with both Server::Lid and Server::Pn; instead
match on the resolved UserLookupKeys from resolve_lookup_keys() and only purge
the valid (user, server) pairs: handle UserLookupKeys::LidWithPn and PnWithLid
by purging lid with Server::Lid and pn with Server::Pn, and handle Unknown by
purging the caller's actual server only (so thread a caller_server parameter
through clear_device_record()/patch_device_remove() into
delete_sessions_for_devices); create a small purge helper used by
delete_sessions_for_devices to convert a (user, server, device_ids) into the
Jid/address and call self.signal_cache.delete_session for each device.

In `@src/handlers/ib.rs`:
- Around line 113-116: Collapse the nested `if let` checks into a single
let-chain: replace the nested `if let Some(routing_info_node) =
child.get_optional_child("routing_info") { if let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... } }` with a single `if let
Some(routing_info_node) = child.get_optional_child("routing_info") && let
Some(NodeContentRef::Bytes(routing_bytes)) =
routing_info_node.content.as_deref() { ... }` pattern so extraction of
`routing_info_node` and `routing_bytes` uses a let-chain; keep the same body
logic and references to `routing_bytes` and `NodeContentRef::Bytes`.

In `@src/handlers/notification.rs`:
- Around line 555-558: The closure building AccountSyncDevice currently maps
optional_u64("key-index") with a silent truncating cast (map(|v| v as u32));
change this to reject overflow by using u32::try_from(v).ok() (or
v.try_into().ok()) so that overflowing key-index values yield None; update the
closure around optional_u64("key-index") and ensure TryFrom/TryInto is in scope
if needed; keep the produced type as Option<u32> to populate
AccountSyncDevice.key_index.

In `@src/message.rs`:
- Around line 376-383: The code is forcing a to_owned() on enc nodes
(enc_node.to_owned()/enc_type.to_string()) before dispatching custom handlers,
breaking zero-copy; change the handler boundary to accept references (e.g.,
&NodeRef or &OwnedNodeRef) instead of owned Node and string so you can pass
enc_node and enc_type by reference directly in the runtime.spawn closure (remove
enc_node_owned/enc_type_owned), update the handler trait/signature
(handler_clone.handle) to take &NodeRef/&OwnedNodeRef and &str/&Cow<str> as
appropriate, and only clone/own inside individual handler implementations when
they truly need ownership. Ensure all call sites and trait impls are updated to
the new reference types.

In `@src/pair_code.rs`:
- Around line 238-245: The code currently converts the 80-byte
NodeContentRef::Bytes to a Vec<u8> (primary_wrapped_ephemeral) causing an
allocation; change the branch to produce a [u8; 80] instead. Update the match
arm that checks Some(NodeContentRef::Bytes(b)) if b.len() == 80 to convert b
into a fixed-size array (e.g., b.try_into() or by copying with copy_from_slice)
and return that [u8; 80]; ensure the variable primary_wrapped_ephemeral’s type
is changed accordingly so downstream code uses the stack-allocated array.

In `@src/pair.rs`:
- Around line 195-203: The code currently uses
optional_jid(...).unwrap_or_default() for parsed_jid and parsed_lid which
silently accepts missing or malformed JIDs; change this so pair-success is
rejected instead: when extracting from success_node -> device_node, parse the
attributes with the existing parser but treat jid and lid as required (fail the
handler/return an Err) if parser.optional_jid("jid") or
parser.optional_jid("lid") yields None or a parse error; do not default to
Jid::default(), and ensure the downstream SetId/SetLid logic only runs when both
parsed_jid and parsed_lid are successfully obtained (mirroring the strict
validation used for device-identity).

In `@src/retry.rs`:
- Around line 532-567: process_retry_key_bundle is redundantly calling
resolve_encryption_jid on requester_jid even though handle_retry_receipt already
resolves the participant; update the API to avoid the extra lookup by changing
process_retry_key_bundle to accept the already-resolved identity (e.g., add a
parameter like resolved_jid: &ResolvedJid or signal_address: &str) and remove
the call to self.resolve_encryption_jid(requester_jid). Locate the resolve call
and the use of signal_address in process_retry_key_bundle, update all call sites
(including handle_retry_receipt) to pass the resolved value, and ensure types
match (use to_protocol_address() at the caller if you pass a string) so no extra
lookup occurs.

In `@tests/e2e/tests/memory_soak.rs`:
- Line 409: Extract the repeated predicate into a single helper function (e.g.,
is_gp2_notification) that accepts the Event (or the node) and returns a bool by
checking matches!(e, Event::Notification(node) if
node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2")); replace the three
inline occurrences with calls to this helper so all references use the same
logic; update any test imports/visibility so memory_soak.rs can call
is_gp2_notification and run the tests to confirm behavior is unchanged.

In `@tests/e2e/tests/offline_groups.rs`:
- Line 147: Extract the inline `w:gp2` check into a shared predicate and use it
in both places (the current inline match using `Event::Notification(node) if
node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2")` and the other
occurrence around line 436). Implement a helper (e.g., `fn
is_group_notification(node: &Node) -> bool` or `fn is_gp2_notification(event:
&Event) -> bool`) that encapsulates `get_attr("type").is_some_and(|v| v.as_str()
== "w:gp2")`, and replace the inline match guards with calls to that helper (for
example `matches!(e, Event::Notification(node) if is_group_notification(node))`)
so the logic is centralized and reused.

In `@wacore/appstate/src/patch_decode.rs`:
- Around line 89-99: The parse_patch_list_ref and parse_patch_lists_ref
functions currently call node.to_owned() and therefore allocate; change the code
so the borrowed NodeRef is threaded end-to-end into parsing helpers: introduce
internal functions (e.g., parse_patch_list_from_ref and
parse_patch_lists_from_ref or make parse_single_collection accept &NodeRef<'_>)
that operate on &NodeRef<'_> without cloning, move the traversal/parse logic
(including parse_single_collection/path descent) into those &NodeRef-taking
helpers, and have parse_patch_list and parse_patch_lists call these helpers or
thinly wrap them so the _ref entry points no longer call to_owned().

In `@wacore/binary/src/decoder.rs`:
- Around line 136-143: The match on the incoming AD_JID "agent" byte in
decoder.rs currently maps unknown values to Server::Pn; instead update the match
in the agent decoding logic so that only the explicit cases (0 => Server::Pn, 1
=> Server::Lid, 128 => Server::Hosted, 129 => Server::HostedLid, and the (n &
128)!=0 && (n & 1)==0 rule) return servers and all other values return a decode
error (do not coerce to Server::Pn). Locate the match on `agent` in the decoder
(the arm producing `server`) and replace the wildcard `_ =>
crate::jid::Server::Pn` with code that fails fast by returning or propagating an
appropriate decode error (create or reuse a DecodeError variant that includes
the offending `agent` byte).

In `@wacore/binary/src/jid.rs`:
- Around line 508-512: The actual_agent() method only zeroes the agent for
Server::Pn but should treat all agent-less server variants the same; update
jid::actual_agent() to return 0 when self.server is Server::Pn, Server::Lid,
Server::Hosted, or Server::HostedLid and return self.agent for all other servers
so its behavior matches Display and is_ad() and avoids leaking hidden agent
state for manually constructed JIDs.
- Around line 624-631: The fast-path parser parse_jid_fast currently coerces
malformed numeric agent/device parts to 0 and causes the early return in the Jid
construction to bypass stricter validation; update parse_jid_fast so it returns
None whenever a numeric component is present but contains non-digit characters
or is out of allowed range (e.g., >65535 or otherwise invalid), ensuring the
caller (code that does if let Some(parts) = parse_jid_fast(s) { ... return
Ok(Jid { ... }) }) falls back to the full parser and triggers the proper
validation; apply the same change to the other fast-path usage locations that
build Jid from parse_jid_fast results so no malformed numeric component is
silently coerced.

In `@wacore/binary/src/node.rs`:
- Around line 663-679: OwnedNodeRef is missing a forwarding helper for
content_as_string(), causing callers to call .get() manually; add a new
#[inline] pub fn content_as_string(&self) -> Option<String> (or matching return
type exposed on NodeRef) on OwnedNodeRef that simply calls
self.get().content_as_string() to mirror NodeRef's API and restore parity
between OwnedNodeRef and NodeRef.
- Around line 589-593: Update the documentation for OwnedNodeRef to remove the
overly strong “zero allocation beyond the buffer itself” claim: state that
OwnedNodeRef avoids copying string/byte payloads out of the decode buffer but
that the inner NodeRef still allocates containers for attributes and child lists
(so there are container allocations). Edit the doc comment above the
OwnedNodeRef struct (and adjust any inline comments on NodeRef if present) to
reflect this accurate allocation behavior and use the symbols OwnedNodeRef and
NodeRef so readers can locate the types.

In `@wacore/derive/src/lib.rs`:
- Around line 185-194: The generated code for AttrType::Jid currently calls
node.attrs().optional_jid(`#attr_name`) directly and never calls the attr-parser's
finish(), which swallows parsing failures; update the try_from_node_ref
generation so both optional and required JID branches create a single parser
variable (e.g. let mut attr_parser = node.attrs();), call
attr_parser.optional_jid(`#attr_name`) or attr_parser.jid/#attr_name accordingly,
then after collecting all fields call attr_parser.finish() and propagate its
error (for required JIDs keep the existing ok_or_else for missing attribute but
return parse errors from finish() instead of masking them); apply the same
change for the other JID-generation site (the similar block around AttrType::Jid
at the second occurrence).

In `@wacore/src/appstate_sync.rs`:
- Around line 98-156: decode_patch_list_ref duplicates the snapshot +
external-mutations download/hydration logic found in decode_patch_list; extract
that shared flow into a single helper (e.g., hydrate_patch_list) and call it
from both entry points. Implement a helper with signature something like fn
hydrate_patch_list<FDownload>(pl: &mut PatchList, download: FDownload) ->
Result<(), Error> where FDownload: Fn(&wa::ExternalBlobReference) ->
Result<Vec<u8>> + Send + Sync; move the snapshot-ref handling
(parse_patch_list_ref result mutation) and the loop that downloads/decodes
external_mutations (with the same logging and patch.mutations replacement) into
it, then have decode_patch_list_ref and decode_patch_list call parse_* to obtain
a PatchList, call hydrate_patch_list(&mut pl, download) and finally call
self.process_patch_list(pl, validate_macs). Ensure signatures and Send+Sync
bounds match the original download closure usage.

In `@wacore/src/iq/blocklist.rs`:
- Around line 116-131: Replace the duplicated parsing logic in parse_response
with a call to BlocklistResponse::try_from_node_ref() so the existing traversal
of optional <list> vs direct <item> and the shared warn-on-parse-error behavior
is reused; locate the parse_response method in blocklist.rs and have it delegate
to BlocklistResponse::try_from_node_ref(response) (and return or map its Result
appropriately) instead of reimplementing the item collection/filter_map logic.

In `@wacore/src/iq/business.rs`:
- Around line 55-61: The node_text function unnecessarily clones bytes with
b.to_vec() before UTF-8 validation; instead validate the borrowed slice directly
and allocate only once by using std::str::from_utf8 on the borrowed bytes
(NodeContentRef::Bytes(b)) and then map the &str to String (e.g.,
from_utf8(b).map(|s| s.to_string()).ok()), leaving the match arms for
NodeContentRef::String and the fallback unchanged.

In `@wacore/src/iq/chatstate.rs`:
- Around line 140-153: optional_jid() conflates "missing" and "malformed" by
returning None for both; update the from and participant handling to check
attribute presence first and fail on parse errors instead of falling through to
the happy path: call attrs.get("from") (or check attrs.has("from")) before using
attrs.optional_jid("from") and if the attribute exists but optional_jid returned
None, return a parse error (create/use a ChatstateParseError::MalformedFrom or a
generic malformed-jid variant) instead of emitting MissingFrom or proceeding; do
the same for the "participant" attribute so that
attrs.optional_jid("participant") returning None when the attribute was present
yields an error rather than silently constructing a ChatstateSource::Group vs
1:1.

In `@wacore/src/iq/groups.rs`:
- Around line 379-382: The current parsing uses
attrs.optional_string("type").and_then(|s|
ParticipantType::try_from(s.as_ref()).ok()).unwrap_or(ParticipantType::Member)
which silently converts unknown/invalid participant type strings into
ParticipantType::Member; change this so an invalid/non-matching string causes
the parse to fail instead of defaulting: attempt to convert with
ParticipantType::try_from and if it returns Err propagate/return a parse error
(or use ? to bubble the error) rather than falling back to Member, so that
unknown types are not coerced and admin/superadmin state is preserved.

In `@wacore/src/iq/keepalive.rs`:
- Around line 61-69: The test test_keepalive_spec_build_iq should explicitly
assert the keepalive destination to lock the wire contract; after creating the
iq via KeepaliveSpec::new().build_iq() add an assertion that iq.to equals the
expected JID for the server (e.g. Jid::new("s.whatsapp.net", Server::Pn) or the
equivalent string) so that any future change to Jid mapping fails the test;
reference KeepaliveSpec::new, build_iq, test_keepalive_spec_build_iq, Jid::new
and Server::Pn when adding this assertion.

In `@wacore/src/iq/mediaconn.rs`:
- Around line 303-317: The attr parser in try_from_node_ref is not finalized so
numeric parse failures are swallowed; after reading ttl, auth_ttl, max_buckets,
set_ip_token (and other optional_* calls) call attrs.finish() and propagate any
error so malformed numeric attributes cause the IQ parse to fail rather than
silently returning 0/None; update the function to invoke attrs.finish() (and
return its Err wrapped via anyhow! or ? as appropriate) before constructing and
returning the MediaConn.

In `@wacore/src/iq/node.rs`:
- Around line 22-26: The helper required_attr currently eagerly allocates by
calling v.to_string(); change it to return Result<std::borrow::Cow<'_, str>,
anyhow::Error> and map the Option from node.get_attr(key) into a Cow so borrowed
slices are preserved when possible (e.g., map(|v| Cow::Borrowed(v)) or
Cow::from(v) that yields borrowed for &str and owned when necessary), keeping
the same error on missing key; update callers if needed to accept Cow or call
into_owned() where an owned String is required.

In `@wacore/src/iq/usync.rs`:
- Around line 122-128: The parse_lid_jid helper (and the other sites noted)
currently calls attrs().optional_string("val") then parses into Jid, causing an
allocation and reparse; replace those with attrs().optional_jid("val") so the
decoder returns a ValueRef::Jid zero-copy Jid directly. Locate usages in
parse_lid_jid and the similar handlers around the other ranges (lines
referenced) that call attrs().optional_string("val") and swap them to
attrs().optional_jid("val") while preserving the same Option<Jid> return/flow.

In `@wacore/src/media_retry.rs`:
- Around line 178-183: The code allocates by calling into_owned() on the
attribute string; keep it borrowed to avoid allocation in the zero-copy parser
by removing into_owned() and using a &str (or Cow<'_, str> if necessary) for
msg_id; update usages that expect an owned String to accept &str (e.g., the
variable msg_id created from node.get_attr("id") and any downstream calls in the
decryption and stanza-id validation logic) so the id is validated and used
without cloning.
- Around line 97-107: The function decrypt_media_retry_notification currently
calls Nonce::from_slice(iv) which will panic if iv is not 12 bytes; validate the
iv length before constructing the nonce and return a handled error instead of
allowing a panic. In decrypt_media_retry_notification, after
derive_media_retry_key and before Aes256Gcm/Nonce usage, check iv.len() == 12
and if not return Err(anyhow!("invalid IV length: expected 12 bytes, got {}",
iv.len())); then safely call Nonce::from_slice(iv) and proceed with decryption
using Aes256Gcm.

In `@wacore/src/pair.rs`:
- Around line 85-98: build_ack_node_ref duplicates the ACK IQ construction from
build_ack_node; refactor by extracting the shared IQ-building logic into a
single helper (e.g., a private fn build_ack_iq(to: &str, id: &str) -> Node or
similar) and have both build_ack_node_ref and build_ack_node call that helper;
update build_ack_node_ref to extract "to" and "id" from the NodeRef (using
NodeRef::get_attr) and pass them to the new helper instead of constructing a
NodeBuilder locally (referencing build_ack_node, build_ack_node_ref,
NodeBuilder, and NodeRef to locate code).

In `@wacore/src/prekeys.rs`:
- Around line 144-149: The helper extract_bytes_ref currently clones bytes into
a Vec<u8> then callers copy into fixed-size arrays; change it to return a
borrowed slice (Result<&[u8], anyhow::Error>) so callers can do zero-copy reads
and only copy into arrays at the final boundary. Update the signature of
extract_bytes_ref (currently fn extract_bytes_ref(node: Option<&NodeRef<'_>>) ->
Result<Vec<u8>, anyhow::Error>) to return a lifetime-linked &[u8] tied to the
NodeRef input, adjust the match arm for NodeContentRef::Bytes to return the
slice reference, and update all call sites (e.g., places extracting registration
ID) to accept a &[u8] and perform the fixed-size array copy there.

In `@wacore/src/stanza/devices.rs`:
- Around line 317-321: The code currently sets stanza_id using
node.get_attr("id").map(|v| v.as_str()).unwrap_or_default().into_owned(), which
converts missing or malformed ids into an empty string; change this to treat the
id as required: replace the unwrap_or_default usage with an explicit match or
if-let that returns an early error/skip when node.get_attr("id") is None or
invalid (e.g., return Err(...) or continue from the surrounding parsing
function), using the same surrounding function's error/flow handling so
ACK/dedup logic never sees a synthetic empty stanza_id; reference symbols:
stanza_id and node.get_attr("id") to locate the change.

In `@wacore/src/types/events.rs`:
- Around line 420-421: The Event enum currently derives Serialize while variants
Notification(Arc<OwnedNodeRef>) and RawNode(Arc<OwnedNodeRef>) are annotated
with #[serde(skip)], which makes serializing those variants fail at runtime;
either remove Serialize from Event or implement a custom Serialize for Event
that handles those two variants explicitly (e.g., match on Event in impl
Serialize for Event and for Notification and RawNode emit a placeholder/unit
form or a lightweight tag instead of trying to serialize Arc<OwnedNodeRef>,
while delegating normal serialization to the other variants). Locate the Event
enum and modify the derives or add an impl Serialize that matches on Event and
serializes Notification and RawNode as unit/placeholder values to avoid runtime
errors.

In `@wacore/src/usync.rs`:
- Around line 153-154: The function parse_lid_mappings_from_response currently
accepts &NodeRef<'_> which is inconsistent with the sibling APIs; change its
signature to accept &Node (matching parse_get_user_devices_response_with_phash
and parse_get_user_devices_response) and update any internal uses to obtain a
NodeRef via Node::as_ref or similar only inside the function so external callers
keep using &Node; ensure the function name parse_lid_mappings_from_response
remains unchanged and adjust imports/types inside the body to compile with
&Node.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49522472-13f8-441d-9415-cdf5d1e45832

📥 Commits

Reviewing files that changed from the base of the PR and between fafb553 and 4469c2e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (120)
  • .gitignore
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/benches/binary_benchmark.rs
  • wacore/binary/src/attrs.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs
  • wacore/tests/binary_protocol_test.rs

Comment thread wacore/src/iq/groups.rs Outdated
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch 2 times, most recently from d74ee95 to c035344 Compare April 12, 2026 12:55

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
wacore/src/types/jid.rs (1)

5-8: 🧹 Nitpick | 🔵 Trivial

Consider moving the mapping logic into the Server enum.

Since Server is now a typed enum with a fixed set of variants, the "s.whatsapp.net" → "c.us" mapping could be implemented as a method on Server (e.g., Server::as_signal_str()) rather than performing a runtime string comparison. This would eliminate the branch and make the mapping intent clearer.

♻️ Proposed refactor

Add a method to the Server enum in wacore_binary:

// In wacore_binary's Server enum implementation
impl Server {
    pub fn as_signal_str(&self) -> &'static str {
        match self {
            Server::Pn => "c.us",  // s.whatsapp.net mapped to c.us
            Server::Lid => "lid",
            Server::Group => "g.us",
            // ... other variants return their canonical form
        }
    }
}

Then update this file:

-#[inline]
-fn mapped_server(s: &str) -> &str {
-    if s == "s.whatsapp.net" { "c.us" } else { s }
-}
-
 pub fn write_protocol_address_to(jid: &Jid, buf: &mut String) {
     use std::fmt::Write;
     buf.clear();
-    let server = mapped_server(jid.server.as_str());
+    let server = jid.server.as_signal_str();
     // ...
 }

Apply similar changes to lines 27-28 and 72.

Would you like me to generate a verification script to confirm all call sites of mapped_server or check if this helper is used elsewhere in the codebase?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/jid.rs` around lines 5 - 8, Replace the ad-hoc mapped_server
function with a method on the Server enum: add a pub fn as_signal_str(&self) ->
&'static str in the Server impl that returns "c.us" for Server::Pn and the
canonical strings for other variants; then remove mapped_server and update all
call sites that currently call mapped_server(s) to call server.as_signal_str()
(or Server::as_signal_str(&server)) — specifically update the places in this
file that previously compared to "s.whatsapp.net" and any usages noted in the
review to use the new Server::as_signal_str method so the mapping is type-driven
rather than a runtime string comparison.
src/types/enc_handler.rs (1)

5-20: ⚠️ Potential issue | 🟠 Major

Change EncHandler::handle signature to accept a zero-copy reference type.

The dispatcher converts from NodeRef to owned Node at line 377 of src/message.rs (let enc_node_owned = (*enc_node).to_owned()) before calling the handler, which negates the zero-copy benefit. The trait definition at line 9 requires &Node instead of a borrowable reference. Consider accepting &NodeRef<'_> or &OwnedNodeRef to eliminate this forced materialization while maintaining a stable public API.

Evidence: conversion at dispatch site
// src/message.rs:376-377
// Custom enc handlers take &Node (public API); convert from NodeRef here.
let enc_node_owned = (*enc_node).to_owned();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/enc_handler.rs` around lines 5 - 20, Change the EncHandler::handle
signature from taking &Node to a zero-copy borrowable type (e.g. &NodeRef<'_> or
&OwnedNodeRef) and update the dispatcher that currently does let enc_node_owned
= (*enc_node).to_owned() so it passes the NodeRef directly into handle; adjust
the trait declaration (EncHandler::handle) and all implementations to accept the
new borrow type and update any callers to avoid forcing .to_owned(), ensuring
async_trait bounds and Send+Sync remain satisfied for the trait object.
src/client/lid_pn.rs (1)

142-161: 🛠️ Refactor suggestion | 🟠 Major

Use let-chains instead of nested if let in the PN branch.

This nested if let violates the repository's collapsible-if pattern guideline. Per AGENTS.md: "Always use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks. Clippy's collapsible_if lint will reject the nested form."

♻️ Proposed refactor
-        } else if target.is_pn() {
-            // PN JID - check if we have a LID mapping
-            if let Some(lid_user) = self.lid_pn_cache.get_current_lid(&target.user).await {
+        } else if target.is_pn()
+            && let Some(lid_user) = self.lid_pn_cache.get_current_lid(&target.user).await
+        {
                 let lid_jid = Jid {
                     user: lid_user.into(),
                     server: wacore_binary::Server::Lid,
                     device: target.device,
                     agent: target.agent,
                     integrator: target.integrator,
                 };
                 debug!(
                     "[SEND-LOCK] Resolved {} to LID {} for session lock",
                     target, lid_jid
                 );
                 lid_jid
-            } else {
-                // No LID mapping - use PN as-is
-                debug!("[SEND-LOCK] No LID mapping for {}, using PN", target);
-                target.clone()
-            }
+        } else if target.is_pn() {
+            // No LID mapping - use PN as-is
+            debug!("[SEND-LOCK] No LID mapping for {}, using PN", target);
+            target.clone()
         } else {
             // Other server type - use as-is
             target.clone()
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/lid_pn.rs` around lines 142 - 161, Replace the nested `if let` by
evaluating the async lookup first, then using a let-chain combining the PN check
and the optional result; e.g. call `let maybe_lid_user =
self.lid_pn_cache.get_current_lid(&target.user).await;` and then use `if
target.is_pn() && let Some(lid_user) = maybe_lid_user { ... }` to build the
`Jid` (symbols: target.is_pn(), self.lid_pn_cache.get_current_lid,
maybe_lid_user, lid_jid, Jid) so the collapsible-if pattern is satisfied and the
nested `if let` is removed.
src/pair.rs (1)

61-70: 🧹 Nitpick | 🔵 Trivial

Move codes into the spawned task instead of cloning it.

codes.clone() duplicates every QR payload even though codes is never read again after this point.

♻️ Minimal diff
                     let (stop_tx, stop_rx) = async_channel::bounded::<()>(1);
-                    let codes_clone = codes.clone();
                     let client_clone = client.clone();
 
                     client
                         .runtime
                         .spawn(Box::pin(async move {
@@
-                            for code in codes_clone {
+                            for code in codes {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 61 - 70, The code currently clones `codes` into
`codes_clone` and iterates over it inside the spawned task; instead take
ownership of `codes` into the async task to avoid duplicating payloads—remove
the `codes_clone = codes.clone()` line, drop uses of `codes_clone`, and let the
`async move` closure capture `codes` directly (iterate `for code in codes { ...
}`) when calling `client.runtime.spawn(...)`; ensure no other code after this
block reads `codes` so the move is valid.
src/client.rs (3)

4505-4508: 🛠️ Refactor suggestion | 🟠 Major

Use DeviceCommand for test setup too.

These setups bypass the same persistence path production code uses, so they can miss snapshot/cache side effects. As per coding guidelines "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

Also applies to: 4587-4590

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4505 - 4508, The test directly mutates device
state via pm.modify_device(|device| { device.pn = ... }) which bypasses
persistence and cache snapshot logic; instead construct an appropriate
DeviceCommand to set the phone number (e.g., a SetPhoneNumber or UpdateDevice
command), call PersistenceManager::process_command(command).await to apply it,
and then read back the device using PersistenceManager::get_device_snapshot() to
verify state; replace the pm.modify_device usages (including the other
occurrence) with this command+process_command+get_device_snapshot flow so side
effects and snapshots match production paths.

3191-3194: 🧹 Nitpick | 🔵 Trivial

Keep the ping sender as a Jid.

parser.jid("from") already gives you a typed JID, but this path stringifies it and build_pong takes String, which adds an avoidable allocation on every server ping.

♻️ Suggested change
-            let pong = build_pong(from_jid.to_string(), id.as_deref());
+            let pong = build_pong(&from_jid, id.as_deref());
-fn build_pong(to: String, id: Option<&str>) -> wacore_binary::Node {
+fn build_pong(to: &Jid, id: Option<&str>) -> wacore_binary::Node {
     let mut builder = NodeBuilder::new("iq").attr("to", to).attr("type", "result");
     if let Some(id) = id {
         builder = builder.attr("id", id);
     }
     builder.build()

Also applies to: 3618-3624

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3191 - 3194, The code currently calls
parser.jid("from") to get a typed JID then calls to_string() and passes that
String into build_pong, causing an unnecessary allocation; change build_pong to
accept a &Jid (or add an overload accepting &Jid) so you can pass the Jid
reference directly (use from_jid or &from_jid) instead of stringifying, and
update the callers (the ping handler around parser.jid("from") / let from_jid
and the similar block at the other ping site) to pass the Jid by reference into
build_pong before awaiting self.send_node(pong). Ensure build_pong’s signature
and any internal uses are adjusted to work with &Jid without allocating.

1732-1736: 🧹 Nitpick | 🔵 Trivial

Skip the device snapshot read for non-message ACKs.

build_ack_node only needs own_device_pn for <message> ACKs, so the unconditional snapshot fetch here adds avoidable async work to every receipt/notification/call ACK.

♻️ Suggested change
-        let device_snapshot = self.persistence_manager.get_device_snapshot().await;
-        let ack = match build_ack_node(node, device_snapshot.pn.as_ref()) {
+        let own_device_pn = if node.tag == "message" {
+            self.persistence_manager.get_device_snapshot().await.pn
+        } else {
+            None
+        };
+        let ack = match build_ack_node(node, own_device_pn.as_ref()) {
             Some(ack) => ack,
             None => return Ok(()),
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1732 - 1736, Avoid an unconditional device
snapshot read: first call build_ack_node(node, None) and if it returns Some(ack)
use that; only if that returns None then await
self.persistence_manager.get_device_snapshot() and call build_ack_node(node,
device_snapshot.pn.as_ref()) and handle the None case (return Ok(())). This
keeps the snapshot fetch (get_device_snapshot / device_snapshot.pn.as_ref())
only for ACKs that actually require own_device_pn (e.g., <message> ACKs) while
leaving other ACK paths unchanged.
wacore/src/iq/mediaconn.rs (1)

375-385: ⚠️ Potential issue | 🟠 Major

Don't silently drop malformed <host> nodes.

The ok()? inside filter_map converts any MediaConnHostExtended::try_from_node_ref error into a skipped entry. A bad host then produces a “successful” MediaConnResponse with an incomplete host list instead of failing the IQ parse.

🐛 Suggested fix
-        let mut hosts: Vec<MediaConnHost> = media_conn_node
-            .get_children_by_tag("host")
-            .filter_map(|host_node| {
-                let ext = MediaConnHostExtended::try_from_node_ref(host_node).ok()?;
-                Some(MediaConnHost {
-                    hostname: ext.hostname,
-                    host_type: ext.host_type,
-                    fallback_hostname: ext.fallback_hostname,
-                })
-            })
-            .collect();
+        let mut hosts: Vec<MediaConnHost> = media_conn_node
+            .get_children_by_tag("host")
+            .map(MediaConnHostExtended::try_from_node_ref)
+            .map(|result| {
+                result.map(|ext| MediaConnHost {
+                    hostname: ext.hostname,
+                    host_type: ext.host_type,
+                    fallback_hostname: ext.fallback_hostname,
+                })
+            })
+            .collect::<Result<Vec<_>, _>>()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 375 - 385, The code currently uses
filter_map with ok()? which swallows errors from
MediaConnHostExtended::try_from_node_ref and yields an incomplete hosts Vec;
instead change the host parsing to collect results as a
Result<Vec<MediaConnHost>, E> (e.g. map each host_node to
MediaConnHostExtended::try_from_node_ref and then convert to MediaConnHost) and
propagate the first error up so the IQ parse fails rather than silently skipping
malformed <host> nodes; update the code that builds hosts (the
media_conn_node.get_children_by_tag("host") chain and the creation of
MediaConnHost from MediaConnHostExtended) to return Err on any parse failure and
surface that error out of the surrounding parse function.
wacore/src/usync.rs (1)

163-170: 🧹 Nitpick | 🔵 Trivial

Use the typed JID attr parser here instead of stringifying and reparsing.

optional_string("jid").parse() allocates and revalidates a value that optional_jid("jid") can already decode directly. That gives back part of the typed/zero-copy win this PR is introducing.

♻️ Suggested cleanup
-        let user_jid_str = match user_node.attrs().optional_string("jid") {
-            Some(jid) => jid,
-            None => continue,
-        };
-        let user_jid: Jid = match user_jid_str.parse() {
-            Ok(j) => j,
-            Err(_) => continue,
-        };
+        let user_jid = match user_node.attrs().optional_jid("jid") {
+            Some(jid) => jid,
+            None => continue,
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/usync.rs` around lines 163 - 170, The code is allocating and
reparsing the JID string; replace the two-step optional_string("jid") + parse
with the typed parser optional_jid("jid") on the attrs to avoid allocation and
revalidation. In the block that defines user_jid_str and user_jid, call
user_node.attrs().optional_jid("jid") (or the equivalent method on Attrs) and
match on Some(jid) / None to continue, preserving the same variable name
(user_jid) and the same early-continue behavior.
♻️ Duplicate comments (26)
wacore/src/pair.rs (1)

85-98: 🧹 Nitpick | 🔵 Trivial

Keep the ACK builder in one place.

build_ack_node_ref now mirrors build_ack_node field-for-field, so the next ACK-shape change can drift again. Please funnel both entry points through one private builder.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/pair.rs` around lines 85 - 98, Create a single private ACK-builder
and have both public entry points call it: extract the common build logic into a
private function (e.g., fn build_ack_node_impl(to: &str, id: &str) -> Node) and
change build_ack_node and build_ack_node_ref to only obtain the to/id
(build_ack_node already has them; build_ack_node_ref should map attributes
"from" and "id") and then call build_ack_node_impl(to, id). This ensures
build_ack_node_ref and build_ack_node no longer duplicate the NodeBuilder attrs
and centralizes future ACK-shape changes.
wacore/src/types/events.rs (1)

420-421: ⚠️ Potential issue | 🟠 Major

#[serde(skip)] still makes these Event variants fail serialization.

On enum variants, Serde treats skip as “never serialize/deserialize,” so Event::Notification and Event::RawNode will error when serialized instead of serializing the enum without the payload. That still breaks the Event: Serialize contract for the new zero-copy variants.

Does Serde's `#[serde(skip)]` on an enum variant cause serializing that variant to return an error rather than omit the payload?

Also applies to: 473-474

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 420 - 421, The enum-level
#[serde(skip)] causes Serde to error when serializing those variants; instead
annotate the variant payload fields to be skipped for (de)serialization. Update
the Event enum so the payloads read like Notification(#[serde(skip_serializing,
skip_deserializing)] Arc<OwnedNodeRef>) and likewise for
RawNode(#[serde(skip_serializing, skip_deserializing)] ...), leaving the enum
variants themselves intact; apply the same change to the other occurrence
referenced (RawNode) so the Event::Notification and Event::RawNode variants
serialize as unit-like variants rather than failing.
tests/e2e/tests/memory_soak.rs (1)

409-409: 🧹 Nitpick | 🔵 Trivial

Consolidate the repeated w:gp2 notification predicate.

The same predicate appears in three places; extracting one helper avoids drift and keeps future node-API migrations safer.

♻️ Suggested refactor
+fn is_gp2_notification(e: &Event) -> bool {
+    matches!(
+        e,
+        Event::Notification(node)
+            if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2")
+    )
+}
...
-            .wait_for_event(15, |e| {
-                matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2"))
-            })
+            .wait_for_event(15, is_gp2_notification)
             .await?;
...
-        .wait_for_event(15, |e| {
-            matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2"))
-        })
+        .wait_for_event(15, is_gp2_notification)
         .await?;
...
-            .wait_for_event(15, |e| {
-                matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v| v.as_str() == "w:gp2"))
-            })
+            .wait_for_event(15, is_gp2_notification)
             .await?;

Also applies to: 429-429, 551-551

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/memory_soak.rs` at line 409, Extract the repeated predicate
matches!(e, Event::Notification(node) if node.get_attr("type").is_some_and(|v|
v.as_str() == "w:gp2")) into a single helper function (e.g., fn is_w_gp2(node:
&Node) -> bool or a closure) and replace the three inline occurrences with calls
to that helper; locate uses around the Event::Notification matching logic in the
tests (the match expression using node.get_attr("type") and "w:gp2") and ensure
the helper is visible where the tests run so all three places (lines with the
same predicate) call the new helper to avoid duplication.
wacore/src/media_retry.rs (2)

178-182: 🧹 Nitpick | 🔵 Trivial

Keep msg_id borrowed in this ref-based parser.

into_owned() reintroduces an allocation on the zero-copy path even though the ID is only passed through to decryption and compared in-scope. A borrowed Cow/&str is enough here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 178 - 182, The code currently calls
into_owned() on the ID from node.get_attr("id"), causing an unnecessary
allocation; keep msg_id as a borrowed &str (or a Cow::Borrowed) by removing
into_owned() and propagating that borrowed type to the downstream
decryption/comparison call sites (where msg_id is used) so those functions
accept &str/Cow as needed; update the variable declaration for msg_id and any
signatures that require an owned String to accept a borrowed &str/Cow instead.

208-213: ⚠️ Potential issue | 🔴 Critical

Validate enc_iv before decrypting.

This path forwards untrusted <enc_iv> bytes straight into decrypt_media_retry_notification(), which still calls Nonce::from_slice(iv). In aes-gcm, a non-12-byte IV panics instead of returning an error, so a malformed notification can crash the parser. Add the length guard in decrypt_media_retry_notification() so every caller gets a handled error.

🛡️ Suggested fix
 pub fn decrypt_media_retry_notification(
     media_key: &[u8],
     stanza_id: &str,
     iv: &[u8],
     ciphertext: &[u8],
 ) -> Result<wa::MediaRetryNotification> {
+    if iv.len() != ENC_IV_SIZE {
+        return Err(anyhow!(
+            "invalid enc_iv length: expected {}, got {}",
+            ENC_IV_SIZE,
+            iv.len()
+        ));
+    }
     let key = derive_media_retry_key(media_key)?;
     let cipher =
         Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("AES-GCM key init failed: {e}"))?;
In the RustCrypto aes-gcm crate, does Nonce::from_slice(iv) panic when iv.len() != 12 for Aes256Gcm?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/media_retry.rs` around lines 208 - 213, The code passes untrusted
enc_iv bytes from encrypt_node into decrypt_media_retry_notification which calls
Nonce::from_slice(iv) and can panic for non-12-byte IVs; modify
decrypt_media_retry_notification to validate that iv.len() == 12 (or the
required nonce length for Aes256Gcm) and return a clear Err(anyhow!(...)) when
the length is wrong, rather than calling Nonce::from_slice on invalid input, so
callers like the callsite using enc_iv (from
encrypt_node.get_optional_child_by_tag) receive a handled error instead of
crashing.
src/client/device_registry.rs (1)

301-313: ⚠️ Potential issue | 🟠 Major

Preserve the resolved (user, server) pairs when deleting sessions.

lookup.all_keys() already loses which alias is LID vs PN, and this loop cross-products every key with both Server::Lid and Server::Pn. For a mapped user that can purge invalid combinations like pn@lid / lid@s.whatsapp.net, and in the worst case delete an unrelated session if the numeric identifiers collide. Match on UserLookupKeys here and only delete the exact alias/server pairs that were actually resolved; for Unknown, use the caller's real server instead of both variants.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 301 - 313, The current
delete_sessions_for_devices implementation uses lookup.all_keys() and then pairs
every key with both Server::Lid and Server::Pn, which can delete incorrect
alias/server combinations; change delete_sessions_for_devices to iterate the
resolved UserLookupKeys variants returned by resolve_lookup_keys(user) (inspect
the enum/struct from resolve_lookup_keys) and for each variant construct the
exact (user, server) pair when building Jid so you only call
signal_cache.delete_session for the exact alias/server combinations originally
resolved; for the Unknown variant, use the caller-provided server (do not try
both Lid and Pn), and ensure you still loop over device_ids and call Jid::new
and JidExt::to_protocol_address before signal_cache.delete_session.
wacore/appstate/src/patch_decode.rs (1)

89-99: ⚠️ Potential issue | 🟠 Major

NodeRef entry points still clone the whole tree.

parse_patch_list_ref() and parse_patch_lists_ref() immediately call node.to_owned(), so every “zero-copy” parse still allocates the full subtree. That erases the main benefit of these APIs and makes the new _ref surface misleading. Please push the borrowed form through parse_patch_list / parse_patch_lists / parse_single_collection instead of round-tripping through Node.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/appstate/src/patch_decode.rs` around lines 89 - 99, The new
parse_patch_list_ref and parse_patch_lists_ref are defeating zero-copy by
calling node.to_owned(); instead, make the borrowed NodeRef flow into the
parsing pipeline: update or overload parse_patch_list / parse_patch_lists (or
add internal helpers used by them and parse_single_collection) to accept
&NodeRef<'_> and implement parsing logic on the borrowed NodeRef rather than
converting to Node via to_owned(), then have parse_patch_list_ref and
parse_patch_lists_ref call those NodeRef-taking functions; ensure symbols
referenced are parse_patch_list_ref, parse_patch_lists_ref, parse_patch_list,
parse_patch_lists, and parse_single_collection and remove the to_owned()
round-trip so no full subtree allocation occurs.
src/pair_code.rs (1)

238-245: 🧹 Nitpick | 🔵 Trivial

Avoid heap-allocating the fixed 80-byte wrapped ephemeral.

This branch already rejects every length except 80, so to_vec() adds unnecessary heap traffic on the pairing hot path. Parse it into [u8; 80] with try_into() and pass that directly into the decrypt step.

♻️ Minimal change
-    let primary_wrapped_ephemeral = match reg_node
+    let primary_wrapped_ephemeral: [u8; 80] = match reg_node
         .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
         .and_then(|n| match n.content.as_deref() {
-            Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
+            Some(NodeContentRef::Bytes(b)) => b.as_ref().try_into().ok(),
             _ => None,
         }) {
         Some(b) => b,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair_code.rs` around lines 238 - 245, The code currently heap-allocates
the 80-byte wrapped ephemeral by calling to_vec() in the match for
reg_node.get_optional_child_by_tag(...)->NodeContentRef::Bytes, which is
wasteful because lengths != 80 are already rejected; change the match to convert
the slice to a fixed-size array using try_into() (i.e., parse into [u8; 80]) and
bind that array to primary_wrapped_ephemeral so it can be passed by value into
the subsequent decrypt step (remove the unnecessary Vec allocation and adjust
downstream uses to accept [u8; 80]).
wacore/derive/src/lib.rs (1)

185-194: ⚠️ Potential issue | 🟠 Major

Generated JID parsing still drops parse errors.

optional_jid() can record a malformed attribute and return None; because the generated code never finalizes that parser state, optional JID fields silently discard invalid wire data and required JID fields misreport it as “missing”. Please generate a single attr-parser binding here and propagate its finish() error path before constructing Self.

Also applies to: 329-335

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/derive/src/lib.rs` around lines 185 - 194, The generated code for
AttrType::Jid must not call optional_jid() or jid parsing inline; instead bind
the attribute parser once (e.g., let parsed =
node.attrs().optional_jid(`#attr_name`) or node.attrs().jid(`#attr_name`)), then
call parsed.finish().map_err(|e| ::anyhow::anyhow!(...))? to propagate parsing
errors before using the value to construct the field (`#field_ident`); do this for
both the required and optional branches so malformed JIDs produce a proper
finish() error instead of returning None or being treated as "missing".
wacore/src/iq/usync.rs (1)

121-128: 🧹 Nitpick | 🔵 Trivial

Use optional_jid() in the remaining JID attribute paths.

These branches still stringify and re-parse JIDs, so ValueRef::Jid responses lose the zero-copy win and pay an extra allocation/parse cycle. Please switch the val, jid, and pn_jid reads to direct JID extraction as well.

Also applies to: 141-145, 316-326, 558-561

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/usync.rs` around lines 121 - 128, The parse_lid_jid function
(and the other JID attribute branches reading "val", "jid", and "pn_jid")
currently call .attrs().optional_string(...).and_then(|s|
s.parse::<Jid>().ok()), causing extra allocation/parse; replace those chains
with the zero-copy accessor .attrs().optional_jid("<attr_name>") so you return
Option<Jid> directly (e.g., in parse_lid_jid use
get_optional_child("lid").and_then(|n| n.attrs().optional_jid("val"))), and
apply the same change to the other helper/sites that read "jid" and "pn_jid" so
all JID reads use optional_jid() and keep the return types as Option<Jid>.
wacore/src/iq/blocklist.rs (1)

116-131: 🧹 Nitpick | 🔵 Trivial

Reuse BlocklistResponse::try_from_node_ref here.

Lines 117-131 duplicate the same <list>/direct-<item> traversal and warning behavior already implemented in BlocklistResponse. Keeping both copies makes future schema tweaks easy to fix in one path and miss in the other.

Minimal cleanup
     fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response> {
-        // BlocklistResponse checks for a <list> child or direct <item> children
-        let entries = if let Some(list) = response.get_optional_child("list") {
-            list.get_children_by_tag("item")
-        } else {
-            response.get_children_by_tag("item")
-        }
-        .filter_map(|item| match BlocklistEntry::try_from_node_ref(item) {
-            Ok(entry) => Some(entry),
-            Err(e) => {
-                warn!(target: "blocklist", "Failed to parse blocklist entry: {e}");
-                None
-            }
-        })
-        .collect();
-        Ok(entries)
+        Ok(BlocklistResponse::try_from_node_ref(response)?.entries)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/blocklist.rs` around lines 116 - 131, The parse_response
implementation duplicates the <list>/<item> traversal and error-handling already
present in BlocklistResponse::try_from_node_ref; replace the manual traversal in
parse_response with a call to BlocklistResponse::try_from_node_ref (or the
existing BlocklistResponse helper) to obtain the entries, preserving its warning
behavior and result type so future schema changes are centralized; update
parse_response to return the result from that helper and remove the duplicated
filtering/collect logic currently using BlocklistEntry::try_from_node_ref and
warn!.
wacore/src/iq/chatstate.rs (1)

140-147: ⚠️ Potential issue | 🟡 Minor

Malformed from JIDs still collapse into MissingFrom.

Lines 141-147 use optional_jid("from"), so a bad from value is indistinguishable from a missing one unless the parser explicitly inspects recorded attr errors. That leaves ChatstateParseError::InvalidJid effectively unreachable.

Run this to verify the attr parser behavior. Expected result: if optional_jid() records parse failures and returns None, parse() should switch to a required JID parse or inspect the recorded errors before returning MissingFrom/SelfEcho.

#!/bin/bash
set -euo pipefail

echo "=== optional_jid implementation ==="
fd -a 'attrs.rs' | while read -r f; do
  rg -n 'fn optional_jid\(' "$f" -A14 -B4
done

echo "=== chatstate parse() context ==="
sed -n '135,155p' wacore/src/iq/chatstate.rs

echo "=== attr error inspection in chatstate parser ==="
rg -n '\.errors\(|attrs\.errors' wacore/src/iq/chatstate.rs -A2 -B2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/chatstate.rs` around lines 140 - 147, The parser uses
attrs.optional_jid("from") so malformed JIDs are treated the same as missing
ones and never yield ChatstateParseError::InvalidJid; update parse() in
chatstate.rs to detect parse failures from attrs (either call
attrs.required_jid("from") when optional_jid returned None but attrs.errors()
contains a JID parse error, or inspect attrs.errors() after optional_jid to
convert recorded JID parse errors into ChatstateParseError::InvalidJid),
ensuring you still preserve the SelfEcho branch when "to" is present; reference
the functions/methods optional_jid, required_jid (if available), attrs.errors(),
and the error variants ChatstateParseError::InvalidJid, ::MissingFrom,
::SelfEcho when making the change.
src/retry.rs (1)

532-567: 🧹 Nitpick | 🔵 Trivial

Don’t resolve the requester JID twice.

The caller already resolves this on Line 223, but Line 566 resolves it again inside process_retry_key_bundle(). That adds another lookup on every retry and makes the parameter name misleading.

Minimal cleanup
     async fn process_retry_key_bundle(
         &self,
         node: &NodeRef<'_>,
-        requester_jid: &wacore_binary::Jid,
+        resolved_jid: &wacore_binary::Jid,
         is_peer: bool,
     ) -> Result<(), anyhow::Error> {
@@
-        let resolved_jid = self.resolve_encryption_jid(requester_jid).await;
         let signal_address = resolved_jid.to_protocol_address();
@@
-            u32::from(requester_jid.device).into(),
+            u32::from(resolved_jid.device).into(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 532 - 567, process_retry_key_bundle currently
re-resolves the requester JID with
self.resolve_encryption_jid(requester_jid).await even though the caller already
resolved it; change the function to accept the already-resolved value (e.g., a
resolved_jid or signal_address) instead of resolving internally, remove the
duplicate call and the local resolved_jid variable, and update all call sites
that invoke process_retry_key_bundle to pass the pre-resolved value (or its
to_protocol_address()) so the misleading parameter name requester_jid is
consistent with its usage and no extra lookup occurs; keep the function name
process_retry_key_bundle and update parameter list and references inside the
function accordingly.
wacore/src/stanza/devices.rs (1)

317-321: ⚠️ Potential issue | 🟠 Major

Reject device notifications without an id.

Line 320 turns a missing or malformed id into "". That loses the stanza handle this struct is supposed to carry and makes downstream ACK/dedup logic ambiguous. Parse it as required instead of defaulting.

Minimal fix
-        let stanza_id = node
-            .get_attr("id")
-            .map(|v| v.as_str())
-            .unwrap_or_default()
-            .into_owned();
+        let stanza_id = required_attr(node, "id")?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/stanza/devices.rs` around lines 317 - 321, The code currently
defaults a missing or invalid stanza id to an empty string (the stanza_id
assignment using node.get_attr("id").unwrap_or_default()), which loses the
stanza handle; change this to treat id as required: when node.get_attr("id") is
None or not valid, return an error or early-none (i.e., propagate Result::Err or
Option::None from the surrounding function) instead of calling into_owned on an
empty default so the calling ACK/dedup logic can detect and reject device
notifications without an id; update the surrounding function signature/return
path that constructs the Stanza/device struct accordingly and reference the
stanza_id variable and node.get_attr("id") call when making the change.
src/handlers/notification.rs (1)

555-558: ⚠️ Potential issue | 🟠 Major

Use a fallible conversion for key-index.

Line 557 narrows an untrusted u64 with as u32, so oversized wire values silently wrap and can store the wrong key index for a companion device. Drop only the invalid key-index instead of corrupting it.

Minimal fix
         .filter_map(|n| {
             let jid = n.attrs().optional_jid("jid")?;
-            let key_index = n.attrs().optional_u64("key-index").map(|v| v as u32);
+            let key_index = n
+                .attrs()
+                .optional_u64("key-index")
+                .and_then(|v| u32::try_from(v).ok());
             Some(AccountSyncDevice { jid, key_index })
         })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 555 - 558, The closure that builds
AccountSyncDevice currently narrows an untrusted u64 to u32 with `as`, which
silently wraps oversized values; change the conversion to a fallible one so
invalid key-index values are dropped. Replace the `.map(|v| v as u32)`
conversion on `n.attrs().optional_u64("key-index")` with a fallible conversion
(e.g., use `and_then(|v| v.try_into().ok())` or check `v <= u32::MAX`) so
`key_index` becomes `None` for out-of-range values, leaving the rest of the
`filter_map` and `AccountSyncDevice { jid, key_index }` logic intact.
src/pair.rs (1)

195-200: ⚠️ Potential issue | 🟠 Major

Reject missing or malformed pair-success jid/lid instead of defaulting them.

optional_jid(...).unwrap_or_default() still lets an invalid <device> block flow into SetId/SetLid, so pairing can persist empty account identifiers and look successful.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pair.rs` around lines 195 - 200, The code currently defaults
missing/malformed device jid/lid via
parser.optional_jid(...).unwrap_or_default(), allowing empty/invalid IDs to
proceed; instead, validate and reject the pair-success when jid or lid are
missing or malformed: in the block handling
success_node/get_optional_child_by_tag("device") inspect
parser.optional_jid("jid") and parser.optional_jid("lid") and if either returns
Err or None propagate an error/return early (do not use unwrap_or_default), so
SetId/SetLid are only called with valid parsed_jid and parsed_lid.
src/message.rs (1)

376-383: 🛠️ Refactor suggestion | 🟠 Major

Custom enc handlers still force a full <enc> clone.

to_owned() rematerializes the payload right before dispatch, so the zero-copy migration stops at the custom-handler boundary. Since this PR is already carrying breaking API changes, this is a good point to switch the handler trait to borrowed NodeRef/OwnedNodeRef and only clone in handlers that truly need ownership.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 376 - 383, The code currently calls
enc_node.to_owned() and enc_type.to_string() before invoking
handler_clone.handle, forcing a full <enc> clone; change the handler trait to
accept borrowed references (e.g., NodeRef and an optional OwnedNodeRef) so you
can pass a borrowed &NodeRef into handler_clone.handle without rematerializing
the payload, and only call .to_owned() (or construct OwnedNodeRef) inside the
handler implementations that need ownership; update the trait signature (handle)
and all implementors to accept &NodeRef (and provide an OwnedNodeRef type for
callers that truly require ownership), then remove enc_node.to_owned() and pass
the borrowed enc_node directly in the runtime.spawn closure.
wacore/src/iq/node.rs (1)

22-25: 🧹 Nitpick | 🔵 Trivial

Keep required_attr zero-copy.

Returning String here forces an allocation on every required attribute read and pushes callers off the borrowed path. Cow<'_, str> would match optional_attr and the rest of this migration better.

♻️ Minimal diff
-pub(crate) fn required_attr(node: &NodeRef<'_>, key: &str) -> Result<String, anyhow::Error> {
-    node.get_attr(key)
-        .map(|v| v.to_string())
+pub(crate) fn required_attr<'a>(
+    node: &'a NodeRef<'_>,
+    key: &str,
+) -> Result<Cow<'a, str>, anyhow::Error> {
+    node.attrs()
+        .optional_string(key)
         .ok_or_else(|| anyhow!("missing required attribute {key}"))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/node.rs` around lines 22 - 25, required_attr currently forces
an owned String; change it to return Result<std::borrow::Cow<'_, str>,
anyhow::Error> to keep zero-copy like optional_attr: update the function
signature of required_attr to Result<Cow<'_, str>, anyhow::Error>, map the
Option<&str> from node.get_attr(key) into a Cow (borrowed) instead of
to_string(), and keep the same ok_or_else missing-attribute error; also update
callers (if any) to accept a Cow<'_, str> rather than String.
wacore/src/iq/groups.rs (1)

379-382: ⚠️ Potential issue | 🟠 Major

Stop coercing unknown participant types to Member.

This brings back the silent downgrade path: an unexpected type now parses as Member instead of failing, which can erase admin/superadmin state in group metadata.

🐛 Proposed fix
-        let participant_type = attrs
-            .optional_string("type")
-            .and_then(|s| ParticipantType::try_from(s.as_ref()).ok())
-            .unwrap_or(ParticipantType::Member);
+        let participant_type =
+            ParticipantType::try_from(attrs.optional_string("type").as_deref())?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/groups.rs` around lines 379 - 382, The code silently coerces
unknown participant types to Member by ending the chain with
.unwrap_or(ParticipantType::Member); change this so invalid values do not get
downgraded: remove the .unwrap_or(...) and instead propagate a parse error when
ParticipantType::try_from(...) fails (i.e., treat invalid strings as an error
rather than defaulting), by mapping the Result from ParticipantType::try_from
into the function's error path (or returning a descriptive Err) where
participant_type is constructed from attrs.optional_string and
ParticipantType::try_from.
src/client.rs (2)

4900-4902: 🧹 Nitpick | 🔵 Trivial

Drop the redundant node_to_owned_ref shim.

This wrapper only forwards to crate::test_utils::node_to_owned_ref unchanged, so it is just another helper to keep in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 4900 - 4902, The function node_to_owned_ref
currently just forwards to crate::test_utils::node_to_owned_ref and is
redundant; delete the shim fn node_to_owned_ref(...) from this module and update
any callers in this file to call crate::test_utils::node_to_owned_ref(&node) (or
pass ownership/signature as needed) so there’s a single canonical implementation
to maintain; ensure imports/usages compile after removing the wrapper.

2299-2317: ⚠️ Potential issue | 🟡 Minor

Don’t remove the ACK waiter before the fallible re-materialization.

response_waiters.remove(&id) happens before marshal_ref(...) / OwnedNodeRef::new(...). If that conversion fails, the caller only gets a canceled channel and loses the real failure cause. Materialize first, or change the waiter payload to carry an explicit error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 2299 - 2317, The code removes the ACK waiter via
self.response_waiters.lock().await.remove(&id) before performing the fallible
re-materialization (wacore_binary::marshal::marshal_ref and
wacore_binary::OwnedNodeRef::new), losing the real error if conversion fails;
change the flow to perform marshal_ref(...) and OwnedNodeRef::new(...) first and
only after successful Ok(onr) lock and remove the waiter and send Arc::new(onr),
or alternatively change the waiter payload type used by response_waiters to
carry a Result<OwnedNodeRef, Error> so you can remove the waiter early but send
Err(...) on failure; update usages of waiter.send(...) accordingly to send the
proper success or error value.
wacore/src/iq/mediaconn.rs (1)

303-317: ⚠️ Potential issue | 🟠 Major

Finish attribute parsing before returning media-conn responses.

optional_u64() records bad numeric attrs on the parser. Both functions read ttl/auth_ttl/max_buckets/set_ip_token and return without attrs.finish(), so malformed values are silently coerced to 0/None.

🐛 Suggested fix
         let ttl = attrs.optional_u64("ttl").unwrap_or(0);
         let auth_ttl = attrs.optional_u64("auth_ttl");
         let max_buckets = attrs.optional_u64("max_buckets");
         let ip_token = attrs.optional_string("ip_token").map(|s| s.into_owned());
         let set_ip_token = attrs.optional_u64("set_ip_token");
+        attrs.finish()?;
         let ttl = attrs.optional_u64("ttl").unwrap_or(0);
         let auth_ttl = attrs.optional_u64("auth_ttl");
         let max_buckets = attrs.optional_u64("max_buckets");
+        attrs.finish()?;

Also applies to: 364-371

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/mediaconn.rs` around lines 303 - 317, In try_from_node_ref (and
the similar parsing block around lines 364-371) finish parsing attributes by
calling attrs.finish() before returning the constructed MediaConn; if
attrs.finish() returns an error propagate or convert it into an anyhow::Error
and return it so malformed numeric attributes (from
optional_u64/optional_string) are surfaced instead of being silently coerced to
0/None. Ensure the attrs.finish() call is placed after reading
ttl/auth_ttl/max_buckets/set_ip_token/ip_token and before Ok(...) so
parser-recorded errors are handled.
wacore/binary/src/decoder.rs (1)

109-115: ⚠️ Potential issue | 🟡 Minor

Add a regression test for unknown JID_PAIR servers.

The fail-fast behavior lives here now, but the decoder tests still never assert that an invalid server returns BinaryError::AttrParse(..). Without that coverage, the old Server::Pn fallback can slip back in unnoticed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/decoder.rs` around lines 109 - 115, Add a regression test
that verifies read_jid_pair returns BinaryError::AttrParse when the server
string is invalid: construct input bytes that exercise read_value_as_string()
twice (first for user, second for a non‑standard server token), call
decoder.read_jid_pair(), and assert the result is
Err(BinaryError::AttrParse(_)); this ensures the Server::try_from(...) path in
read_jid_pair triggers the error rather than falling back to Server::Pn.
wacore/binary/src/jid.rs (1)

63-79: ⚠️ Potential issue | 🟠 Major

Reject malformed numeric parts on the fast path instead of coercing them to zero.

parse_jid_fast() still accepts bad device/agent components by falling back to 0, so inputs like 123:abc@s.whatsapp.net or user.300@hosted can bypass the validating fallback and turn into different valid JIDs. These branches should return None on parse failure/out-of-range so FromStr falls back to the strict path.

Also applies to: 84-89, 121-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 63 - 79, The fast-path in
parse_jid_fast currently coerces malformed numeric parts to 0 (e.g.,
device/agent/integrator) which lets invalid inputs slip through; change the
parsing branches in parse_jid_fast (the HIDDEN_USER_SERVER branch that builds
ParsedJidParts and the similar numeric parses at the other fast-path locations
referenced) to attempt numeric parsing with proper range checks and return None
on parse errors or out-of-range values instead of using unwrap_or(0), so FromStr
will fall back to the strict parsing path when any numeric component is invalid.
wacore/src/prekeys.rs (2)

137-139: 🧹 Nitpick | 🔵 Trivial

Drop the _ref suffix from the remaining private prekey helpers.

At this point &NodeRef<'_> is the only code path here, so the suffix just adds noise and makes this migration feel half-finished.

Also applies to: 211-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/prekeys.rs` around lines 137 - 139, Rename the remaining helper
functions that end with the _ref suffix (e.g., node_to_pre_key_bundle_ref) to
drop the suffix (e.g., node_to_pre_key_bundle), keep their current signatures
(they still take &NodeRef<'_>), and update all internal call sites to use the
new names so the migration is consistent; apply the same rename pattern to the
other private prekey helper functions referenced in the same region (the helpers
in the 211–263 block) ensuring no public API changes and running tests to
confirm nothing else references the old names.

144-147: ⚠️ Potential issue | 🟠 Major

Keep the prekey parser zero-copy through the fixed-size byte fields.

These branches still to_vec() borrowed NodeContentRef::Bytes and then immediately length-check/copy them into arrays. That gives back heap traffic right in the middle of the zero-copy migration and on a hot path the PR is explicitly optimizing.

Also applies to: 241-257, 270-286


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 93df1c6b-f4d8-4f76-8fd4-63c717290232

📥 Commits

Reviewing files that changed from the base of the PR and between 4469c2e and d74ee95.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (120)
  • .gitignore
  • Cargo.toml
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/features/blocking.rs
  • src/features/chat_actions.rs
  • src/features/chatstate.rs
  • src/features/community.rs
  • src/features/contacts.rs
  • src/features/groups.rs
  • src/features/media_reupload.rs
  • src/features/newsletter.rs
  • src/features/polls.rs
  • src/features/presence.rs
  • src/features/signal.rs
  • src/features/status.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/router.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/jid_utils.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/pending_device_sync.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/session.rs
  • src/spam_report.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/presence.rs
  • tests/e2e/tests/privacy_tokens.rs
  • wacore/appstate/src/patch_decode.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/benches/binary_benchmark.rs
  • wacore/binary/src/attrs.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/node.rs
  • wacore/derive/src/lib.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/blocklist.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/chatstate.rs
  • wacore/src/iq/contacts.rs
  • wacore/src/iq/devices.rs
  • wacore/src/iq/dirty.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/keepalive.rs
  • wacore/src/iq/mediaconn.rs
  • wacore/src/iq/mex.rs
  • wacore/src/iq/node.rs
  • wacore/src/iq/passive.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/privacy.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/props.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/spec.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/iq/usync.rs
  • wacore/src/media_retry.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/pair.rs
  • wacore/src/pair_code.rs
  • wacore/src/prekeys.rs
  • wacore/src/proto_helpers.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/reporting_token.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/session.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/devices.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/stanza/message.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs
  • wacore/src/types/call.rs
  • wacore/src/types/events.rs
  • wacore/src/types/jid.rs
  • wacore/src/types/message.rs
  • wacore/src/types/spam_report.rs
  • wacore/src/usync.rs
  • wacore/src/xml.rs
  • wacore/tests/binary_protocol_test.rs

Comment thread src/handlers/ib.rs
Comment on lines +122 to +131
client
.runtime
.spawn(Box::pin(async move {
client_clone
.persistence_manager
.modify_device(|device| {
device.edge_routing_info = Some(routing_bytes);
})
.await;
}))

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist edge-routing info through the device command path, not modify_device().

This directly mutates Device state from the handler path. That bypasses the repo’s required serialization/update mechanism and makes this write inconsistent with the rest of the state-management flow. As per coding guidelines "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() and read via get_device_snapshot()".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/ib.rs` around lines 122 - 131, The code currently mutates
Device.edge_routing_info inside the async spawn by calling
persistence_manager.modify_device(), which bypasses the repo's
serialization/update flow; replace that direct mutation with constructing the
appropriate DeviceCommand that sets edge_routing_info and submit it to
persistence_manager.process_command() (await its completion) inside the spawned
task (use client_clone and persistence_manager as before), and ensure any reads
rely on persistence_manager.get_device_snapshot() rather than reading Device
directly; remove the modify_device() usage entirely and use DeviceCommand +
PersistenceManager::process_command() to persist the change.

Comment thread tests/e2e/tests/presence.rs Outdated
Comment thread wacore/binary/src/jid.rs
Comment on lines 356 to +357
fn is_empty(&self) -> bool {
self.server().is_empty()
self.user().is_empty()

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

is_empty() now misclassifies valid server-only JIDs as empty.

This file explicitly treats server-only JIDs as valid, but self.user().is_empty() returns true for all of them. That already has a downstream effect in wacore/src/request.rs Line 184, where !target.is_empty() gates whether the IQ target attribute is emitted—so a legitimate server-only target will now be dropped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/jid.rs` around lines 356 - 357, is_empty() currently
returns self.user().is_empty(), which treats server-only JIDs as empty; change
it to consider both parts so a JID is empty only if both user() and server() are
empty—i.e., update is_empty() to return self.user().is_empty() &&
self.server().is_empty() (use the existing user() and server() accessors so
downstream code like the IQ target emission in request.rs correctly recognizes
server-only JIDs).

Comment thread wacore/src/iq/groups.rs
Comment on lines 617 to 629
let member_add_mode = node
.get_optional_child_by_tag(&["member_add_mode"])
.and_then(|n| match &n.content {
Some(NodeContent::String(s)) => MemberAddMode::try_from(s.as_str()).ok(),
.and_then(|n| match n.content.as_deref() {
Some(NodeContentRef::String(s)) => MemberAddMode::try_from(s.as_ref()).ok(),
_ => None,
});

let member_link_mode = node
.get_optional_child_by_tag(&["member_link_mode"])
.and_then(|n| match &n.content {
Some(NodeContent::String(s)) => MemberLinkMode::try_from(s.as_str()).ok(),
.and_then(|n| match n.content.as_deref() {
Some(NodeContentRef::String(s)) => MemberLinkMode::try_from(s.as_ref()).ok(),
_ => None,
});

ghost Apr 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't treat invalid member mode values as if the field were absent.

Both parsers swallow enum conversion failures with .ok(). If the server sends an unexpected member_add_mode or member_link_mode, callers now see None and lose the actual group permission state instead of getting a parse error.

🐛 Proposed fix
-        let member_add_mode = node
-            .get_optional_child_by_tag(&["member_add_mode"])
-            .and_then(|n| match n.content.as_deref() {
-                Some(NodeContentRef::String(s)) => MemberAddMode::try_from(s.as_ref()).ok(),
-                _ => None,
-            });
+        let member_add_mode = match node.get_optional_child_by_tag(&["member_add_mode"]) {
+            Some(n) => match n.content.as_deref() {
+                Some(NodeContentRef::String(s)) => Some(MemberAddMode::try_from(s.as_ref())?),
+                Some(_) => return Err(anyhow!("expected string content in <member_add_mode>")),
+                None => None,
+            },
+            None => None,
+        };

-        let member_link_mode = node
-            .get_optional_child_by_tag(&["member_link_mode"])
-            .and_then(|n| match n.content.as_deref() {
-                Some(NodeContentRef::String(s)) => MemberLinkMode::try_from(s.as_ref()).ok(),
-                _ => None,
-            });
+        let member_link_mode = match node.get_optional_child_by_tag(&["member_link_mode"]) {
+            Some(n) => match n.content.as_deref() {
+                Some(NodeContentRef::String(s)) => Some(MemberLinkMode::try_from(s.as_ref())?),
+                Some(_) => return Err(anyhow!("expected string content in <member_link_mode>")),
+                None => None,
+            },
+            None => None,
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/groups.rs` around lines 617 - 629, The parsers for
member_add_mode and member_link_mode currently swallow enum conversion failures
by calling .ok() on MemberAddMode::try_from and MemberLinkMode::try_from,
treating invalid values as absent; change the logic in the parsing code that
uses get_optional_child_by_tag (the member_add_mode and member_link_mode parsing
blocks) to propagate a parse error instead of returning None — e.g. inspect
NodeContentRef::String(s), call try_from(s.as_ref()) and map the Err into the
function's error/Result (or use the ? operator) so invalid server-sent values
produce an error rather than quietly becoming None.

Comment thread wacore/src/iq/prekeys.rs
Comment thread wacore/src/iq/tctoken.rs Outdated
Replace Arc<Node> with Arc<OwnedNodeRef> throughout the handler chain.
OwnedNodeRef wraps Yoke<NodeRef<'static>, Vec<u8>>, keeping the decoded
NodeRef borrowing directly from the decompressed buffer — zero
allocation for attribute keys, values, and byte content.

Changes:
- Add yoke dependency, derive Yokeable on
  NodeRef/ValueRef/JidRef/NodeContentRef
- Define OwnedNodeRef: self-referential zero-copy node via Yoke
- decrypt_frame() returns OwnedNodeRef instead of Node
- StanzaHandler trait takes Arc<OwnedNodeRef>
- All handlers use node.get() to access &NodeRef
- Client methods accept &NodeRef<'_> instead of &Node
- Event::Notification and Event::RawNode use Arc<OwnedNodeRef>
- NodeWaiter/NodeFilter adapted to OwnedNodeRef
- Message handler channel uses Arc<OwnedNodeRef>
- Message processing uses &NodeRef throughout
- IQ response waiters still use Node via .to_owned_node() (infrequent)
- Re-export OwnedNodeRef and Server from whatsapp-rust for clean API

BREAKING CHANGE: Handler trait signature changes from Arc<Node> to
Arc<OwnedNodeRef>. Event::Notification wraps Arc<OwnedNodeRef> instead
of Node. Event::RawNode wraps Arc<OwnedNodeRef> instead of Arc<Node>.
Access node data via node.get() which returns &NodeRef. Use
.to_owned_node() when an owned Node is needed.
@jlucaso1
jlucaso1 force-pushed the perf/yoke-zero-copy-and-server-enum branch from c035344 to 916b6ff Compare April 12, 2026 13:23
@jlucaso1
jlucaso1 merged commit bfe434a into main Apr 12, 2026
@jlucaso1
jlucaso1 deleted the perf/yoke-zero-copy-and-server-enum branch April 12, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant